batplot 1.8.0__py3-none-any.whl → 1.8.2__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 +5 -3
- batplot/batplot.py +44 -4
- batplot/cpc_interactive.py +96 -3
- batplot/electrochem_interactive.py +28 -0
- batplot/interactive.py +18 -2
- batplot/modes.py +12 -12
- batplot/operando.py +2 -0
- batplot/operando_ec_interactive.py +112 -11
- batplot/session.py +35 -1
- batplot/utils.py +40 -0
- batplot/version_check.py +85 -6
- {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
- batplot-1.8.2.dist-info/RECORD +75 -0
- {batplot-1.8.0.dist-info → batplot-1.8.2.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.0.dist-info/RECORD +0 -52
- {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
- {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""CLI entry for batplot.
|
|
2
|
+
|
|
3
|
+
This module provides the main entry point for the batplot command-line interface.
|
|
4
|
+
It handles version checking, argument delegation, and error handling.
|
|
5
|
+
|
|
6
|
+
Design principles:
|
|
7
|
+
- Lazy imports: imports happen inside main() to avoid side effects during module load
|
|
8
|
+
- Clean separation: delegates actual work to batplot_main() in batplot.py
|
|
9
|
+
- Non-intrusive: version check is silent and non-blocking (max 2s timeout)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: Optional[list] = None) -> int:
|
|
19
|
+
"""
|
|
20
|
+
Main CLI entry point for batplot.
|
|
21
|
+
|
|
22
|
+
This function is the entry point when users run 'batplot' from the command line.
|
|
23
|
+
It's also the function called by the 'batplot' command installed via pip/setuptools.
|
|
24
|
+
|
|
25
|
+
HOW IT WORKS:
|
|
26
|
+
-----------
|
|
27
|
+
When you type 'batplot file.xy --interactive' in terminal:
|
|
28
|
+
|
|
29
|
+
1. Python's setuptools calls this main() function
|
|
30
|
+
2. main() performs version check (non-blocking, cached)
|
|
31
|
+
3. main() parses command-line arguments
|
|
32
|
+
4. main() delegates to batplot_main() which routes to appropriate mode
|
|
33
|
+
5. Errors are caught and displayed cleanly (no scary tracebacks)
|
|
34
|
+
|
|
35
|
+
WHY LAZY IMPORTS?
|
|
36
|
+
----------------
|
|
37
|
+
We import batplot_main() inside the try block, not at module level.
|
|
38
|
+
This is called "lazy importing" and has benefits:
|
|
39
|
+
- Faster startup: Only imports what's needed when needed
|
|
40
|
+
- Avoids side effects: Some modules might do things at import time
|
|
41
|
+
- Better error handling: Import errors happen in try/except block
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
argv: Optional command line arguments for testing/programmatic use.
|
|
45
|
+
If None, uses sys.argv (normal CLI usage).
|
|
46
|
+
Format: ['--mode', 'file.ext', ...]
|
|
47
|
+
Note: Do NOT include 'batplot' itself, it's added automatically.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Exit code: 0 for success, non-zero for error.
|
|
51
|
+
This follows Unix convention where 0 = success, non-zero = error.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
>>> # Testing mode (programmatic use)
|
|
55
|
+
>>> main(['--cv', 'data.mpt'])
|
|
56
|
+
0
|
|
57
|
+
|
|
58
|
+
>>> # Normal CLI usage (reads from sys.argv)
|
|
59
|
+
>>> main()
|
|
60
|
+
0
|
|
61
|
+
"""
|
|
62
|
+
# ====================================================================
|
|
63
|
+
# STEP 1: VERSION CHECK (NON-BLOCKING)
|
|
64
|
+
# ====================================================================
|
|
65
|
+
# Check PyPI for newer versions of batplot.
|
|
66
|
+
# This is done asynchronously and doesn't block the main program.
|
|
67
|
+
#
|
|
68
|
+
# Features:
|
|
69
|
+
# - Non-blocking: 2 second timeout (won't slow down startup)
|
|
70
|
+
# - Cached: Only checks once per day (saves network requests)
|
|
71
|
+
# - Optional: Can be disabled with BATPLOT_NO_VERSION_CHECK=1 environment variable
|
|
72
|
+
# - Silent failure: If check fails, program continues normally
|
|
73
|
+
#
|
|
74
|
+
# Why check for updates?
|
|
75
|
+
# - Users get notified of bug fixes and new features
|
|
76
|
+
# - Helps maintain an up-to-date installation
|
|
77
|
+
# ====================================================================
|
|
78
|
+
try:
|
|
79
|
+
from . import __version__
|
|
80
|
+
from .version_check import check_for_updates
|
|
81
|
+
check_for_updates(__version__)
|
|
82
|
+
except Exception:
|
|
83
|
+
# Silently ignore any errors in version checking.
|
|
84
|
+
# We NEVER want version checking to crash the main program.
|
|
85
|
+
# If PyPI is down, network is slow, or any other error occurs,
|
|
86
|
+
# the program should continue normally.
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# ====================================================================
|
|
90
|
+
# STEP 2: PREPARE ARGUMENTS (TESTING MODE SUPPORT)
|
|
91
|
+
# ====================================================================
|
|
92
|
+
# If argv is provided (testing/programmatic mode), temporarily replace
|
|
93
|
+
# sys.argv so argparse reads from our test arguments instead of real
|
|
94
|
+
# command line.
|
|
95
|
+
#
|
|
96
|
+
# Why? This allows unit tests to call main() with fake arguments without
|
|
97
|
+
# actually running from command line.
|
|
98
|
+
#
|
|
99
|
+
# Example:
|
|
100
|
+
# main(['--cv', 'test.mpt']) # Test with fake arguments
|
|
101
|
+
# # sys.argv temporarily becomes ['batplot', '--cv', 'test.mpt']
|
|
102
|
+
# ====================================================================
|
|
103
|
+
if argv is not None:
|
|
104
|
+
old_argv = sys.argv # Save original for restoration
|
|
105
|
+
sys.argv = ['batplot'] + list(argv) # Replace with test arguments
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
# ====================================================================
|
|
109
|
+
# STEP 3: DELEGATE TO MAIN FUNCTION
|
|
110
|
+
# ====================================================================
|
|
111
|
+
# Import here (lazy import) to avoid side effects at module import time.
|
|
112
|
+
# batplot_main() will:
|
|
113
|
+
# 1. Parse command-line arguments using args.parse_args()
|
|
114
|
+
# 2. Route to appropriate mode handler (XY, EC, Operando, Batch)
|
|
115
|
+
# 3. Handle mode-specific logic
|
|
116
|
+
# ====================================================================
|
|
117
|
+
from .batplot import batplot_main
|
|
118
|
+
return batplot_main()
|
|
119
|
+
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
# ====================================================================
|
|
122
|
+
# STEP 4: ERROR HANDLING
|
|
123
|
+
# ====================================================================
|
|
124
|
+
# ValueError is used for user-facing errors:
|
|
125
|
+
# - Bad command-line arguments
|
|
126
|
+
# - File not found
|
|
127
|
+
# - Invalid file format
|
|
128
|
+
# - Missing required parameters (e.g., --mass for .mpt files)
|
|
129
|
+
#
|
|
130
|
+
# We print a clean error message without scary Python traceback.
|
|
131
|
+
# This makes errors more user-friendly.
|
|
132
|
+
#
|
|
133
|
+
# Other exceptions (unexpected bugs) will still show full traceback
|
|
134
|
+
# for debugging purposes.
|
|
135
|
+
# ====================================================================
|
|
136
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
137
|
+
return 1 # Non-zero exit code indicates error
|
|
138
|
+
|
|
139
|
+
finally:
|
|
140
|
+
# ====================================================================
|
|
141
|
+
# CLEANUP: RESTORE ORIGINAL sys.argv
|
|
142
|
+
# ====================================================================
|
|
143
|
+
# If we modified sys.argv for testing, restore it now.
|
|
144
|
+
# This ensures tests don't affect each other.
|
|
145
|
+
# ====================================================================
|
|
146
|
+
if argv is not None:
|
|
147
|
+
sys.argv = old_argv
|
|
148
|
+
|
|
149
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Shared helpers for color previews and user-defined color management.
|
|
2
|
+
|
|
3
|
+
COLOR PALETTE SYSTEM OVERVIEW:
|
|
4
|
+
==============================
|
|
5
|
+
This module manages how colors are assigned to multiple curves/lines in batplot plots.
|
|
6
|
+
|
|
7
|
+
HOW COLOR PALETTES WORK:
|
|
8
|
+
------------------------
|
|
9
|
+
When you have many curves (e.g., 100 files in XY mode, or 50 cycles in EC mode), you want
|
|
10
|
+
each one to have a different color that smoothly transitions across a color palette.
|
|
11
|
+
|
|
12
|
+
Example: Using 'viridis' palette with 5 curves:
|
|
13
|
+
Curve 1 → Dark purple (start of viridis)
|
|
14
|
+
Curve 2 → Blue-purple
|
|
15
|
+
Curve 3 → Green
|
|
16
|
+
Curve 4 → Yellow-green
|
|
17
|
+
Curve 5 → Bright yellow (end of viridis)
|
|
18
|
+
|
|
19
|
+
The system works by:
|
|
20
|
+
1. Getting a continuous colormap (e.g., 'viridis')
|
|
21
|
+
2. Sampling colors at evenly spaced points along the colormap
|
|
22
|
+
3. Assigning each sampled color to a different curve
|
|
23
|
+
|
|
24
|
+
For 100 curves, we sample 100 evenly spaced points from the colormap, ensuring each
|
|
25
|
+
curve gets a unique, smoothly varying color.
|
|
26
|
+
|
|
27
|
+
COLORMAP SOURCES:
|
|
28
|
+
----------------
|
|
29
|
+
1. Matplotlib built-in: 'viridis', 'plasma', 'inferno', 'magma', etc.
|
|
30
|
+
2. cmcrameri scientific colormaps: 'batlow', 'batlowk', 'batloww' (if installed)
|
|
31
|
+
3. Custom colormaps: Defined in _CUSTOM_CMAPS dictionary below
|
|
32
|
+
|
|
33
|
+
REVERSED COLORMAPS:
|
|
34
|
+
------------------
|
|
35
|
+
Colormaps can be reversed by adding '_r' suffix:
|
|
36
|
+
'viridis' → normal (dark to bright)
|
|
37
|
+
'viridis_r' → reversed (bright to dark)
|
|
38
|
+
|
|
39
|
+
This is useful when you want the color order flipped.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from typing import List, Optional, Sequence
|
|
45
|
+
|
|
46
|
+
import matplotlib.pyplot as plt
|
|
47
|
+
from matplotlib import colors as mcolors
|
|
48
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
49
|
+
|
|
50
|
+
from .config import get_user_colors as _cfg_get_user_colors
|
|
51
|
+
from .config import save_user_colors as _cfg_save_user_colors
|
|
52
|
+
|
|
53
|
+
# ====================================================================================
|
|
54
|
+
# CUSTOM COLORMAP DEFINITIONS
|
|
55
|
+
# ====================================================================================
|
|
56
|
+
# These are custom color palettes designed for scientific visualization.
|
|
57
|
+
# Each colormap is defined as a list of hex color codes that smoothly transition
|
|
58
|
+
# from one color to the next.
|
|
59
|
+
#
|
|
60
|
+
# Format: List of hex color strings, ordered from start to end of colormap
|
|
61
|
+
# Example: ['#02121d', '#053061', ...] means start with very dark blue, end with light yellow
|
|
62
|
+
#
|
|
63
|
+
# These colormaps are registered with matplotlib so they can be used like built-in ones.
|
|
64
|
+
# ====================================================================================
|
|
65
|
+
_CUSTOM_CMAPS = {
|
|
66
|
+
# 'batlow' - Scientific colormap optimized for colorblind accessibility
|
|
67
|
+
# Colors transition: dark blue → teal → green → yellow
|
|
68
|
+
'batlow': ['#02121d', '#053061', '#2b7a8b', '#7cbf7b', '#c7e6a2', '#f9f0c3'],
|
|
69
|
+
|
|
70
|
+
# 'batlowk' - Variant with more purple tones
|
|
71
|
+
# Colors transition: dark purple → purple → brown → yellow
|
|
72
|
+
'batlowk': ['#150b2d', '#3d2e63', '#5f4f85', '#81718f', '#a6938e', '#cbb58f', '#efd78d'],
|
|
73
|
+
|
|
74
|
+
# 'batloww' - Variant with more blue-green tones
|
|
75
|
+
# Colors transition: dark blue → blue → teal → green → yellow
|
|
76
|
+
'batloww': ['#0a1427', '#17385d', '#295f8d', '#4f8fa3', '#7db7a1', '#b2d39a', '#e3e6a8'],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_colormap(name: Optional[str]) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Ensure that a named colormap exists and is registered with matplotlib.
|
|
83
|
+
|
|
84
|
+
HOW IT WORKS:
|
|
85
|
+
------------
|
|
86
|
+
This function checks if a colormap is available, and if not, tries to register it.
|
|
87
|
+
It searches in this order:
|
|
88
|
+
1. Built-in matplotlib colormaps (viridis, plasma, etc.)
|
|
89
|
+
2. cmcrameri scientific colormaps (if package is installed)
|
|
90
|
+
3. Custom colormaps defined in _CUSTOM_CMAPS
|
|
91
|
+
4. Any other matplotlib-compatible colormap
|
|
92
|
+
|
|
93
|
+
WHY THIS IS NEEDED:
|
|
94
|
+
-------------------
|
|
95
|
+
Different colormap sources need to be registered with matplotlib before they can
|
|
96
|
+
be used. This function ensures the colormap is available regardless of its source.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
name: Colormap name (e.g., 'viridis', 'batlow', 'viridis_r')
|
|
100
|
+
'_r' suffix indicates reversed colormap
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if colormap exists and is registered, False otherwise
|
|
104
|
+
"""
|
|
105
|
+
if not name:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
# Handle reversed colormaps (remove '_r' suffix to get base name)
|
|
109
|
+
# Example: 'viridis_r' → base = 'viridis', we'll reverse it later if needed
|
|
110
|
+
base = name[:-2] if name.lower().endswith('_r') else name
|
|
111
|
+
base_lower = base.lower()
|
|
112
|
+
|
|
113
|
+
# STEP 1: Check if it's already a registered matplotlib colormap
|
|
114
|
+
if base_lower in plt.colormaps():
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
# STEP 2: Try to load from cmcrameri package (scientific colormaps)
|
|
118
|
+
# cmcrameri is an optional package with colorblind-friendly colormaps
|
|
119
|
+
try:
|
|
120
|
+
import cmcrameri.cm as cmc
|
|
121
|
+
if hasattr(cmc, base_lower):
|
|
122
|
+
cmap_obj = getattr(cmc, base_lower)
|
|
123
|
+
try:
|
|
124
|
+
# Register it with matplotlib so it can be used like built-in colormaps
|
|
125
|
+
plt.register_cmap(name=base_lower, cmap=cmap_obj)
|
|
126
|
+
except ValueError:
|
|
127
|
+
# Already registered, that's fine
|
|
128
|
+
pass
|
|
129
|
+
return True
|
|
130
|
+
except Exception:
|
|
131
|
+
# cmcrameri not installed or colormap not found, continue to next step
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
# STEP 3: Check if it's a custom colormap defined in this module
|
|
135
|
+
custom = _CUSTOM_CMAPS.get(base_lower)
|
|
136
|
+
if custom:
|
|
137
|
+
try:
|
|
138
|
+
# Create a LinearSegmentedColormap from the list of colors
|
|
139
|
+
# N=256 means create 256 intermediate colors by interpolating between the given colors
|
|
140
|
+
# This creates a smooth gradient
|
|
141
|
+
cmap_obj = LinearSegmentedColormap.from_list(base_lower, custom, N=256)
|
|
142
|
+
try:
|
|
143
|
+
# Register with matplotlib
|
|
144
|
+
plt.register_cmap(name=base_lower, cmap=cmap_obj)
|
|
145
|
+
except ValueError:
|
|
146
|
+
# Already registered, that's fine
|
|
147
|
+
pass
|
|
148
|
+
return True
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# STEP 4: Final fallback - try to get it directly from matplotlib
|
|
153
|
+
# This handles any other matplotlib-compatible colormap
|
|
154
|
+
try:
|
|
155
|
+
plt.get_cmap(base_lower)
|
|
156
|
+
return True
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _ansi_color_block_from_rgba(rgba) -> str:
|
|
162
|
+
"""Return a two-space block with the given RGBA color."""
|
|
163
|
+
try:
|
|
164
|
+
r, g, b, _ = rgba
|
|
165
|
+
r_i = max(0, min(255, int(round(r * 255))))
|
|
166
|
+
g_i = max(0, min(255, int(round(g * 255))))
|
|
167
|
+
b_i = max(0, min(255, int(round(b * 255))))
|
|
168
|
+
return f"\033[48;2;{r_i};{g_i};{b_i}m \033[0m"
|
|
169
|
+
except Exception:
|
|
170
|
+
return "[??]"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def color_block(color: Optional[str]) -> str:
|
|
174
|
+
"""Return a colored block (ANSI) for the supplied color string."""
|
|
175
|
+
if not color:
|
|
176
|
+
return "[--]"
|
|
177
|
+
try:
|
|
178
|
+
rgba = mcolors.to_rgba(color)
|
|
179
|
+
return _ansi_color_block_from_rgba(rgba)
|
|
180
|
+
except Exception:
|
|
181
|
+
return "[??]"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def color_bar(colors: Sequence[str]) -> str:
|
|
185
|
+
"""Return a string of adjacent color blocks."""
|
|
186
|
+
blocks = [color_block(col) for col in colors if col]
|
|
187
|
+
return " ".join(blocks)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def palette_preview(name: str, steps: int = 8) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Return a visual preview of a colormap as colored blocks in the terminal.
|
|
193
|
+
|
|
194
|
+
HOW IT WORKS:
|
|
195
|
+
------------
|
|
196
|
+
This function samples colors from a colormap at evenly spaced intervals and
|
|
197
|
+
displays them as colored blocks. This lets users see what the colormap looks
|
|
198
|
+
like before applying it to their data.
|
|
199
|
+
|
|
200
|
+
Example output for 'viridis' with 8 steps:
|
|
201
|
+
[dark purple block] [purple block] [blue block] [green block] [yellow block] ...
|
|
202
|
+
|
|
203
|
+
SAMPLING METHOD:
|
|
204
|
+
---------------
|
|
205
|
+
For a colormap with N steps:
|
|
206
|
+
- Step 0: Sample at position 0.0 (start of colormap)
|
|
207
|
+
- Step 1: Sample at position 1/(N-1)
|
|
208
|
+
- Step 2: Sample at position 2/(N-1)
|
|
209
|
+
- ...
|
|
210
|
+
- Step N-1: Sample at position 1.0 (end of colormap)
|
|
211
|
+
|
|
212
|
+
This gives evenly distributed colors across the entire colormap range.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
name: Colormap name (e.g., 'viridis', 'plasma', 'batlow')
|
|
216
|
+
steps: Number of color samples to show (default 8)
|
|
217
|
+
More steps = more detailed preview but longer output
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
String of ANSI color codes that display as colored blocks in terminal
|
|
221
|
+
Empty string if colormap not found
|
|
222
|
+
"""
|
|
223
|
+
# Ensure colormap is registered
|
|
224
|
+
ensure_colormap(name)
|
|
225
|
+
|
|
226
|
+
# Try to get the colormap from matplotlib
|
|
227
|
+
try:
|
|
228
|
+
cmap = plt.get_cmap(name)
|
|
229
|
+
except Exception:
|
|
230
|
+
cmap = None
|
|
231
|
+
# Fallback: try cmcrameri if it's a batlow variant
|
|
232
|
+
lower = name.lower()
|
|
233
|
+
if lower.startswith('batlow'):
|
|
234
|
+
try:
|
|
235
|
+
import cmcrameri.cm as cmc
|
|
236
|
+
if hasattr(cmc, lower):
|
|
237
|
+
cmap = getattr(cmc, lower)
|
|
238
|
+
elif hasattr(cmc, 'batlow'):
|
|
239
|
+
cmap = getattr(cmc, 'batlow')
|
|
240
|
+
except Exception:
|
|
241
|
+
return ""
|
|
242
|
+
else:
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
# Ensure steps is at least 1 (avoid division by zero)
|
|
246
|
+
if steps < 1:
|
|
247
|
+
steps = 1
|
|
248
|
+
|
|
249
|
+
# Special handling for tab10 to use hardcoded colors (matching EC and CPC interactive)
|
|
250
|
+
if name.lower() == 'tab10':
|
|
251
|
+
default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
252
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
253
|
+
# Use first 'steps' colors from tab10
|
|
254
|
+
samples = [default_tab10_colors[i % len(default_tab10_colors)] for i in range(steps)]
|
|
255
|
+
else:
|
|
256
|
+
# Sample colors at evenly spaced positions along the colormap
|
|
257
|
+
# Position ranges from 0.0 (start) to 1.0 (end)
|
|
258
|
+
samples = [
|
|
259
|
+
mcolors.to_hex(cmap(i / max(steps - 1, 1))) # Convert to hex color code
|
|
260
|
+
for i in range(steps)
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
# Convert color codes to visual blocks and return as string
|
|
264
|
+
return color_bar(samples)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _set_cached_colors(fig, colors: List[str]):
|
|
268
|
+
"""
|
|
269
|
+
Store user colors in figure object for fast access (caching).
|
|
270
|
+
|
|
271
|
+
HOW CACHING WORKS:
|
|
272
|
+
-----------------
|
|
273
|
+
Instead of reading from disk every time, we store colors in the figure object.
|
|
274
|
+
This is faster because:
|
|
275
|
+
- Reading from disk is slow (file I/O)
|
|
276
|
+
- Figure object is already in memory (fast access)
|
|
277
|
+
|
|
278
|
+
WHY STORE IN FIGURE OBJECT?
|
|
279
|
+
---------------------------
|
|
280
|
+
The figure object persists throughout the interactive session, so we can
|
|
281
|
+
cache colors there. This avoids repeated file reads.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
fig: Matplotlib figure object (where we store the cache)
|
|
285
|
+
colors: List of color codes to cache
|
|
286
|
+
"""
|
|
287
|
+
if fig is not None:
|
|
288
|
+
# setattr() dynamically adds an attribute to an object
|
|
289
|
+
# This is like: fig._user_colors_cache = list(colors)
|
|
290
|
+
# We use list() to create a copy (not a reference to original list)
|
|
291
|
+
setattr(fig, '_user_colors_cache', list(colors))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_user_color_list(fig=None) -> List[str]:
|
|
295
|
+
"""
|
|
296
|
+
Return cached user colors (persisted to ~/.batplot).
|
|
297
|
+
|
|
298
|
+
HOW IT WORKS:
|
|
299
|
+
------------
|
|
300
|
+
1. First check if colors are cached in figure object (fast path)
|
|
301
|
+
2. If not cached, load from disk (~/.batplot/config.json)
|
|
302
|
+
3. Cache the loaded colors in figure object for next time
|
|
303
|
+
4. Return the color list
|
|
304
|
+
|
|
305
|
+
WHY CACHING?
|
|
306
|
+
-----------
|
|
307
|
+
- Fast: Memory access is much faster than disk I/O
|
|
308
|
+
- Efficient: Only reads from disk once per session
|
|
309
|
+
- Persistent: Colors are saved to disk, so they persist between sessions
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
fig: Matplotlib figure object (optional, for caching)
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
List of color codes (hex strings like '#FF0000' or named colors like 'red')
|
|
316
|
+
"""
|
|
317
|
+
# Check if colors are already cached in figure object
|
|
318
|
+
if fig is not None and hasattr(fig, '_user_colors_cache'):
|
|
319
|
+
# Return cached colors (fast path - no disk access)
|
|
320
|
+
# list() creates a copy so caller can't modify the cached version
|
|
321
|
+
return list(getattr(fig, '_user_colors_cache'))
|
|
322
|
+
|
|
323
|
+
# Not cached - load from disk
|
|
324
|
+
colors = list(_cfg_get_user_colors())
|
|
325
|
+
# Cache for next time
|
|
326
|
+
_set_cached_colors(fig, colors)
|
|
327
|
+
return colors
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _save_user_colors(colors: List[str], fig=None) -> List[str]:
|
|
331
|
+
"""
|
|
332
|
+
Save user colors to disk and cache, removing duplicates and empty entries.
|
|
333
|
+
|
|
334
|
+
HOW IT WORKS:
|
|
335
|
+
------------
|
|
336
|
+
1. Remove empty/None colors (filter out invalid entries)
|
|
337
|
+
2. Remove duplicates (keep only first occurrence of each color)
|
|
338
|
+
3. Save cleaned list to disk (~/.batplot/config.json)
|
|
339
|
+
4. Update cache in figure object
|
|
340
|
+
5. Return cleaned list
|
|
341
|
+
|
|
342
|
+
WHY CLEAN THE LIST?
|
|
343
|
+
------------------
|
|
344
|
+
- Empty strings would cause errors when trying to use them as colors
|
|
345
|
+
- Duplicates waste space and confuse users
|
|
346
|
+
- Clean data = better user experience
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
colors: List of color codes (may contain duplicates or empty strings)
|
|
350
|
+
fig: Matplotlib figure object (optional, for caching)
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Cleaned list (no duplicates, no empty entries)
|
|
354
|
+
"""
|
|
355
|
+
cleaned: List[str] = [] # Type annotation: cleaned is a list of strings
|
|
356
|
+
for col in colors:
|
|
357
|
+
# Skip empty/None colors
|
|
358
|
+
if not col:
|
|
359
|
+
continue
|
|
360
|
+
# Only add if not already in cleaned list (removes duplicates)
|
|
361
|
+
if col not in cleaned:
|
|
362
|
+
cleaned.append(col)
|
|
363
|
+
# Save to disk (persists between sessions)
|
|
364
|
+
_cfg_save_user_colors(cleaned)
|
|
365
|
+
# Update cache (fast access for current session)
|
|
366
|
+
_set_cached_colors(fig, cleaned)
|
|
367
|
+
return cleaned
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def add_user_color(color: str, fig=None) -> List[str]:
|
|
371
|
+
"""Append a user color (if not already present)."""
|
|
372
|
+
colors = get_user_color_list(fig)
|
|
373
|
+
if color and color not in colors:
|
|
374
|
+
colors.append(color)
|
|
375
|
+
colors = _save_user_colors(colors, fig)
|
|
376
|
+
return colors
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def remove_user_color(index: int, fig=None) -> List[str]:
|
|
380
|
+
"""Remove a user color by 0-based index."""
|
|
381
|
+
colors = get_user_color_list(fig)
|
|
382
|
+
if 0 <= index < len(colors):
|
|
383
|
+
colors.pop(index)
|
|
384
|
+
colors = _save_user_colors(colors, fig)
|
|
385
|
+
return colors
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def clear_user_colors(fig=None) -> None:
|
|
389
|
+
_save_user_colors([], fig)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def resolve_color_token(token: str, fig=None) -> str:
|
|
393
|
+
"""
|
|
394
|
+
Translate color references like '2' or 'u3' into actual color codes.
|
|
395
|
+
|
|
396
|
+
HOW IT WORKS:
|
|
397
|
+
------------
|
|
398
|
+
Users can reference saved colors in two ways:
|
|
399
|
+
1. By number: '2' means the 2nd saved color (1-based indexing)
|
|
400
|
+
2. By 'u' prefix: 'u3' means the 3rd saved color (1-based indexing)
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
'2' → Returns colors[1] (2nd color, but 0-based index is 1)
|
|
404
|
+
'u3' → Returns colors[2] (3rd color, but 0-based index is 2)
|
|
405
|
+
'red' → Returns 'red' (not a reference, so return as-is)
|
|
406
|
+
'#FF0000' → Returns '#FF0000' (not a reference, so return as-is)
|
|
407
|
+
|
|
408
|
+
WHY TWO FORMATS?
|
|
409
|
+
---------------
|
|
410
|
+
- '2' is shorter and easier to type
|
|
411
|
+
- 'u3' is more explicit (clearly indicates user color)
|
|
412
|
+
- Both are 1-based (user-friendly) but converted to 0-based (Python indexing)
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
token: Color reference string (e.g., '2', 'u3', 'red', '#FF0000')
|
|
416
|
+
fig: Matplotlib figure object (optional, for accessing cached colors)
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Actual color code (hex string or named color), or original token if not a reference
|
|
420
|
+
"""
|
|
421
|
+
# Empty token - return as-is
|
|
422
|
+
if not token:
|
|
423
|
+
return token
|
|
424
|
+
|
|
425
|
+
# Remove whitespace
|
|
426
|
+
stripped = token.strip()
|
|
427
|
+
idx = None # Will hold the 0-based index if token is a reference
|
|
428
|
+
|
|
429
|
+
# Check if token is 'u' prefix format (e.g., 'u3')
|
|
430
|
+
# stripped[1:] gets everything after the first character
|
|
431
|
+
# .isdigit() checks if it's all digits
|
|
432
|
+
if stripped.lower().startswith('u') and stripped[1:].isdigit():
|
|
433
|
+
# Convert 'u3' → index 2 (3rd color, but 0-based)
|
|
434
|
+
idx = int(stripped[1:]) - 1
|
|
435
|
+
# Check if token is just a number (e.g., '2')
|
|
436
|
+
elif stripped.isdigit():
|
|
437
|
+
# Convert '2' → index 1 (2nd color, but 0-based)
|
|
438
|
+
idx = int(stripped) - 1
|
|
439
|
+
|
|
440
|
+
# If we found a valid index, look up the color
|
|
441
|
+
if idx is not None:
|
|
442
|
+
colors = get_user_color_list(fig)
|
|
443
|
+
# Check if index is valid (within bounds of color list)
|
|
444
|
+
if 0 <= idx < len(colors):
|
|
445
|
+
return colors[idx] # Return the actual color code
|
|
446
|
+
|
|
447
|
+
# Not a reference, or invalid index - return token as-is
|
|
448
|
+
return token
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def print_user_colors(fig=None) -> None:
|
|
452
|
+
"""Print saved colors with indices and color blocks."""
|
|
453
|
+
colors = get_user_color_list(fig)
|
|
454
|
+
if not colors:
|
|
455
|
+
print("No saved user colors.")
|
|
456
|
+
return
|
|
457
|
+
print("Saved colors:")
|
|
458
|
+
for idx, color in enumerate(colors, 1):
|
|
459
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def manage_user_colors(fig=None) -> None:
|
|
463
|
+
"""Interactive submenu for editing user-defined colors."""
|
|
464
|
+
while True:
|
|
465
|
+
colors = get_user_color_list(fig)
|
|
466
|
+
print("\n\033[1mUser color list:\033[0m")
|
|
467
|
+
if colors:
|
|
468
|
+
for idx, color in enumerate(colors, 1):
|
|
469
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
470
|
+
else:
|
|
471
|
+
print(" (empty)")
|
|
472
|
+
print("Options: a=add colors, d=delete numbers, c=clear, q=back")
|
|
473
|
+
choice = input("User colors> ").strip().lower()
|
|
474
|
+
if not choice:
|
|
475
|
+
continue
|
|
476
|
+
if choice == 'q':
|
|
477
|
+
break
|
|
478
|
+
if choice == 'a':
|
|
479
|
+
line = input("Enter colors (space-separated names/hex codes) or q: ").strip()
|
|
480
|
+
if not line or line.lower() == 'q':
|
|
481
|
+
continue
|
|
482
|
+
# List comprehension: splits line by spaces, keeps only non-empty tokens
|
|
483
|
+
# Example: "red blue #FF0000" → ['red', 'blue', '#FF0000']
|
|
484
|
+
# The 'if tok' part filters out empty strings (from multiple spaces)
|
|
485
|
+
new_colors = [tok for tok in line.split() if tok]
|
|
486
|
+
if new_colors:
|
|
487
|
+
colors = get_user_color_list(fig)
|
|
488
|
+
added = 0
|
|
489
|
+
for col in new_colors:
|
|
490
|
+
if col not in colors:
|
|
491
|
+
colors.append(col)
|
|
492
|
+
added += 1
|
|
493
|
+
_save_user_colors(colors, fig)
|
|
494
|
+
print(f"Added {added} color(s).")
|
|
495
|
+
continue
|
|
496
|
+
if choice == 'd':
|
|
497
|
+
if not colors:
|
|
498
|
+
print("No colors to delete.")
|
|
499
|
+
continue
|
|
500
|
+
line = input("Enter number(s) to delete (e.g., 1 or 1,3,5): ").strip()
|
|
501
|
+
if not line:
|
|
502
|
+
continue
|
|
503
|
+
tokens = line.replace(',', ' ').split()
|
|
504
|
+
indices = []
|
|
505
|
+
for tok in tokens:
|
|
506
|
+
if tok.isdigit():
|
|
507
|
+
idx = int(tok) - 1
|
|
508
|
+
if 0 <= idx < len(colors):
|
|
509
|
+
indices.append(idx)
|
|
510
|
+
else:
|
|
511
|
+
print(f"Index out of range: {tok}")
|
|
512
|
+
else:
|
|
513
|
+
print(f"Invalid entry: {tok}")
|
|
514
|
+
if indices:
|
|
515
|
+
# Sort indices in reverse order (largest first)
|
|
516
|
+
# WHY? When deleting multiple items, we must delete from end to start.
|
|
517
|
+
# If we delete index 1 first, then index 3 becomes index 2, and we'd delete the wrong item!
|
|
518
|
+
# Example: colors = ['red', 'blue', 'green', 'yellow']
|
|
519
|
+
# Delete indices [1, 3] (blue and yellow)
|
|
520
|
+
# If we delete 1 first: ['red', 'green', 'yellow'] (index 3 is now out of bounds!)
|
|
521
|
+
# If we delete 3 first: ['red', 'blue', 'green'] (then delete 1: ['red', 'green'] ✓)
|
|
522
|
+
for idx in sorted(indices, reverse=True):
|
|
523
|
+
colors.pop(idx) # Remove color at this index
|
|
524
|
+
_save_user_colors(colors, fig)
|
|
525
|
+
print("Updated color list.")
|
|
526
|
+
continue
|
|
527
|
+
if choice == 'c':
|
|
528
|
+
confirm = input("Clear all saved colors? (y/n): ").strip().lower()
|
|
529
|
+
if confirm == 'y':
|
|
530
|
+
clear_user_colors(fig)
|
|
531
|
+
print("Cleared saved colors.")
|
|
532
|
+
continue
|
|
533
|
+
print("Unknown option.")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
__all__ = [
|
|
537
|
+
'add_user_color',
|
|
538
|
+
'clear_user_colors',
|
|
539
|
+
'color_bar',
|
|
540
|
+
'color_block',
|
|
541
|
+
'manage_user_colors',
|
|
542
|
+
'palette_preview',
|
|
543
|
+
'print_user_colors',
|
|
544
|
+
'remove_user_color',
|
|
545
|
+
'resolve_color_token',
|
|
546
|
+
'get_user_color_list',
|
|
547
|
+
]
|