Skip to content

Commit e2e2e71

Browse files
committed
Improve spectra palettes and legend controls
Add visual palette previews, expanded spectrum color choices, configurable legend limits, persisted options, and faster reliable test cleanup.
1 parent 0110adc commit e2e2e71

14 files changed

Lines changed: 438 additions & 64 deletions

File tree

spectroview/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# The app uses PySide6 throughout, but superqt (used for QLabeledDoubleRangeSlider)
77
os.environ.setdefault("QT_API", "pyside6")
88

9-
VERSION = "26.36.1"
9+
VERSION = "26.36.2"
1010

1111

1212
TEXT_EXPIRE = (
@@ -42,6 +42,8 @@
4242
DEFAULT_COLORS = [
4343
'#E31A1C', '#33A02C', '#FF7F00', '#1F78B4', '#6A3D9A',
4444
'#FB9A99', '#B2DF8A', '#FDBF6F', '#A6CEE3', '#CAB2D6',
45+
'#B15928', '#E6AB02', '#00A6D6', '#F564E3', '#7F7F7F',
46+
'#1B9E77', '#D95F02', '#7570B3', '#E7298A', '#66A61E',
4547
]
4648

4749
MARKERS = [

spectroview/model/m_settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def load_view_options(self) -> dict:
3939
"yaxis": self.settings.value("view_options/yaxis", "Intensity (a.u.)", str),
4040
"yscale": self.settings.value("view_options/yscale", "Linear", str),
4141
"plotstyle": self.settings.value("view_options/plotstyle", "line", str),
42+
"color_palette": self.settings.value(
43+
"view_options/color_palette", "DEFAULT_COLORS", str),
4244
"lw": self.settings.value("view_options/lw", 1.5, float),
4345
"dotsize": self.settings.value("view_options/dotsize", 3.0, float),
4446
"raw": self.settings.value("view_options/raw", False, bool),
@@ -51,6 +53,8 @@ def load_view_options(self) -> dict:
5153
"height": self.settings.value("view_options/height", "4.0", str),
5254
"legend": self.settings.value("view_options/legend", False, bool),
5355
"bestfit": self.settings.value("view_options/bestfit", True, bool),
56+
"max_legend_items": self.settings.value(
57+
"view_options/max_legend_items", 15, int),
5458
"copy_fig_theme": self.settings.value("view_options/copy_fig_theme", "Light Mode", str),
5559
}
5660

spectroview/resources/user_manual/spectra_maps.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ The `SpectraViewer` is the central plotting widget where all spectra selected vi
100100
| ![Bestfit](../user_manual_images/Spectra_Maps/show_bestfit.png) | **Show Bestfit**: Toggles the display of the best-fit curve(s). |
101101
| ![Legend](../user_manual_images/Spectra_Maps/show_legend.png) | **Legend**: Toggles the display of the legend box. When the "Zoom" tool is disabled, you can click directly on the legend box to customize colors and labels. |
102102
| ![Copy](../user_manual_images/Spectra_Maps/copy.png) | **Copy**: Copies the plot to your clipboard as a high-quality image. Use `Ctrl + Click` (or `Cmd + Click` on macOS) to copy the raw numerical plot data to your clipboard instead. |
103-
| ![More View Options](../user_manual_images/Spectra_Maps/view_options.png) | **More Options**: Opens a comprehensive configuration panel allowing you to adjust X/Y units, toggle log scales, change plot styles, toggle Raw/Residual visibility, enable grids, adjust line widths, and define precise figure dimensions.<br>![More Options Panel](../user_manual_images/Spectra_Maps/menu_view_options.png) |
103+
| ![More View Options](../user_manual_images/Spectra_Maps/view_options.png) | **More Options**: Opens a comprehensive configuration panel allowing you to adjust X/Y units, toggle log scales, change plot styles, choose discrete or gradient spectrum color palettes from visual color-strip previews, set the maximum number of legend items (15 by default), toggle Raw/Residual visibility, enable grids, adjust line widths, and define precise figure dimensions.<br>![More Options Panel](../user_manual_images/Spectra_Maps/menu_view_options.png) |
104104

105105
_______
106106

spectroview/view/components/customize_graph/customize_legend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ def _build_legend_widgets(self, legend_properties):
376376
delegate = ColorDelegate(color)
377377
color.setItemDelegate(delegate)
378378

379-
unique_colors = list(dict.fromkeys(DEFAULT_COLORS))[:12]
379+
unique_colors = list(dict.fromkeys(DEFAULT_COLORS))
380380
for color_code in unique_colors:
381381
color.addItem(color_code)
382382
item = color.model().item(color.count() - 1)

spectroview/view/components/customized_widgets.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from PySide6.QtWidgets import QComboBox, QLineEdit, QLabel, QSizePolicy
66

77
import numpy as np
8-
import matplotlib.cm as cm
8+
import matplotlib as mpl
99
from matplotlib.figure import Figure
1010
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas, NavigationToolbar2QT
1111

@@ -46,14 +46,27 @@ def paintEvent(self, event):
4646

4747

4848
class CustomizedPalette(QComboBox):
49-
"""Custom QComboBox to show color palette previews along with their names."""
50-
def __init__(self, palette_list=None, parent=None, icon_size=(99, 12)):
49+
"""QComboBox showing each named palette as a color-strip icon.
50+
51+
``custom_palettes`` allows callers to preview application-defined discrete
52+
color lists alongside Matplotlib colormaps. Matplotlib palettes retain
53+
their existing gradient/segmented rendering, so the same widget can be
54+
shared by map and spectrum viewers without changing the map palette set.
55+
"""
56+
57+
def __init__(self, palette_list=None, parent=None, icon_size=(99, 12),
58+
custom_palettes=None):
5159
super().__init__(parent)
5260
self.icon_width, self.icon_height = icon_size
5361
self.setIconSize(QSize(*icon_size))
5462
self.setMinimumWidth(100)
5563

56-
self.palette_list = palette_list or PALETTE
64+
self.palette_list = list(
65+
PALETTE if palette_list is None else palette_list)
66+
self.custom_palettes = {
67+
name: tuple(colors)
68+
for name, colors in (custom_palettes or {}).items()
69+
}
5770
self._populate_with_previews()
5871

5972
def _populate_with_previews(self):
@@ -63,18 +76,30 @@ def _populate_with_previews(self):
6376
self.addItem(icon, cmap_name)
6477

6578
def _create_colormap_preview(self, cmap_name):
66-
"""Generate a horizontal gradient preview image for the colormap."""
79+
"""Generate a horizontal palette preview image.
80+
81+
Custom palettes are rendered as equal-width color blocks. Registered
82+
Matplotlib colormaps are sampled continuously, which naturally renders
83+
qualitative ``ListedColormap`` palettes as blocks and sequential maps
84+
as gradients.
85+
"""
6786
width, height = self.icon_width, self.icon_height
68-
69-
try:
70-
cmap = cm.colormaps[cmap_name]
71-
except AttributeError:
72-
cmap = cm.get_cmap(cmap_name)
73-
74-
gradient = np.linspace(0, 1, width)
75-
colors = (cmap(gradient) * 255).astype(np.uint8)
87+
88+
custom_colors = self.custom_palettes.get(cmap_name)
89+
if custom_colors:
90+
rgba = mpl.colors.to_rgba_array(custom_colors)
91+
indices = np.minimum(
92+
np.arange(width) * len(rgba) // width,
93+
len(rgba) - 1,
94+
)
95+
sampled_colors = rgba[indices]
96+
else:
97+
cmap = mpl.colormaps[cmap_name]
98+
sampled_colors = cmap(np.linspace(0, 1, width))
99+
100+
colors = np.rint(sampled_colors * 255).astype(np.uint8)
76101
colors_2d = np.tile(colors, (height, 1, 1))
77-
102+
78103
qimage = QImage(colors_2d.data, width, height, width * 4, QImage.Format_RGBA8888)
79104
return QPixmap.fromImage(qimage.copy())
80105

spectroview/view/components/v_spectra_viewer.py

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
QWidget, QVBoxLayout, QHBoxLayout,
1313
QPushButton, QToolButton, QLabel,
1414
QComboBox, QMenu, QWidgetAction,
15-
QLineEdit, QDoubleSpinBox, QColorDialog, QInputDialog,
15+
QLineEdit, QDoubleSpinBox, QSpinBox, QColorDialog, QInputDialog,
1616
QSlider, QMessageBox, QApplication
1717
)
1818
from PySide6.QtCore import QObject, QEvent, Qt, Signal, QSize, QTimer, QPoint
@@ -30,7 +30,25 @@
3030

3131
from spectroview import ICON_DIR, X_AXIS_UNIT, Y_AXIS_UNIT, PLOT_POLICY_LIGHT, PLOT_POLICY_DARK, PLOT_POLICY_SOFT_DARK, DEFAULT_COLORS
3232
from spectroview.viewmodel.utils import copy_fig_to_clb, get_tinted_icon, fano_display_amplitude
33-
from spectroview.view.components.customized_widgets import NoDoubleClickZoomToolbar
33+
from spectroview.view.components.customized_widgets import (
34+
CustomizedPalette,
35+
NoDoubleClickZoomToolbar,
36+
)
37+
38+
39+
SPECTRA_DISCRETE_PALETTES = (
40+
"DEFAULT_COLORS",
41+
"tab20", "tab20b", "tab20c", "Dark2", "Paired", "Accent",
42+
)
43+
SPECTRA_GRADIENT_PALETTES = (
44+
"viridis", "plasma", "jet", "cividis", "magma",
45+
)
46+
SPECTRA_COLOR_PALETTES = (
47+
SPECTRA_DISCRETE_PALETTES + SPECTRA_GRADIENT_PALETTES
48+
)
49+
SPECTRA_CUSTOM_PALETTES = {
50+
"DEFAULT_COLORS": DEFAULT_COLORS,
51+
}
3452

3553

3654
class _MockPeakModelObj:
@@ -389,6 +407,22 @@ def _create_options_menu(self):
389407
self.cbb_plotstyle.currentIndexChanged.connect(self._emit_view_options)
390408
menu.addAction(self._wrap("Spectrum plot style:", self.cbb_plotstyle))
391409

410+
# Spectrum color palette. Qualitative palettes cycle through distinct
411+
# colors; sequential/rainbow palettes are sampled across all selected
412+
# spectra so ordered series (time, temperature, etc.) form a gradient.
413+
self.cbb_color_palette = CustomizedPalette(
414+
palette_list=SPECTRA_COLOR_PALETTES,
415+
custom_palettes=SPECTRA_CUSTOM_PALETTES,
416+
)
417+
self.cbb_color_palette.setCurrentText("DEFAULT_COLORS")
418+
self.cbb_color_palette.setToolTip(
419+
"Choose colors for the selected spectrum series. Custom colors "
420+
"set from the legend continue to take priority."
421+
)
422+
self.cbb_color_palette.currentIndexChanged.connect(
423+
self._emit_view_options)
424+
menu.addAction(self._wrap("Color palette:", self.cbb_color_palette))
425+
392426
# Line width
393427
self.spin_lw = QDoubleSpinBox()
394428
self.spin_lw.setRange(0.1, 5)
@@ -434,12 +468,16 @@ def _create_options_menu(self):
434468
menu.addSeparator()
435469

436470
# Max legend items / heavy overlays
437-
self.spin_max_overlays = QDoubleSpinBox()
438-
self.spin_max_overlays.setRange(1, 1000)
439-
self.spin_max_overlays.setValue(10)
440-
self.spin_max_overlays.valueChanged.connect(self._emit_view_options)
441-
self.spin_max_overlays.valueChanged.connect(self._plot)
442-
menu.addAction(self._wrap("Max legend items:", self.spin_max_overlays))
471+
self.spin_max_legend_items = QSpinBox()
472+
self.spin_max_legend_items.setRange(1, 1000)
473+
self.spin_max_legend_items.setValue(15)
474+
self.spin_max_legend_items.valueChanged.connect(self._emit_view_options)
475+
menu.addAction(self._wrap(
476+
"Max legend items:", self.spin_max_legend_items))
477+
478+
# Backwards-compatible alias for internal/external code that used the
479+
# old name when this control was first introduced.
480+
self.spin_max_overlays = self.spin_max_legend_items
443481

444482
menu.addSeparator()
445483

@@ -612,7 +650,7 @@ def _plot_internal(self):
612650
plot_style = self.cbb_plotstyle.currentText()
613651
lw = self.spin_lw.value()
614652
dot_size = self.spin_dotsize.value()
615-
colors_cycle = self._get_colors_cycle()
653+
colors_cycle = self._get_colors_cycle(len(self._tensor_data["y"]))
616654

617655
# ── Step 1: Build bulk segment data (main spectra + raw overlay) ──
618656
segments = self._build_tensor_segments(
@@ -631,11 +669,51 @@ def _plot_internal(self):
631669
# Plot helpers: segment builders
632670
# ─────────────────────────────────────────────
633671

634-
def _get_colors_cycle(self):
635-
"""Return the current matplotlib color cycle or a sensible default."""
636-
prop_cycle = plt.rcParams.get('axes.prop_cycle')
637-
return (prop_cycle.by_key()['color'] if prop_cycle
638-
else DEFAULT_COLORS)
672+
def _get_colors_cycle(self, count=None):
673+
"""Return colors sampled from the selected spectrum palette.
674+
675+
Discrete palettes repeat only after all of their distinct colors have
676+
been used. Gradient palettes are sampled evenly from end to end, which
677+
makes the spectrum order visible for time/temperature series.
678+
"""
679+
palette_name = (
680+
self.cbb_color_palette.currentText()
681+
if hasattr(self, "cbb_color_palette") else "DEFAULT_COLORS"
682+
)
683+
requested_count = (
684+
len(DEFAULT_COLORS) if count is None else max(0, int(count))
685+
)
686+
687+
if palette_name in SPECTRA_CUSTOM_PALETTES:
688+
base_colors = list(SPECTRA_CUSTOM_PALETTES[palette_name])
689+
else:
690+
try:
691+
cmap = mpl.colormaps[palette_name]
692+
except (KeyError, AttributeError):
693+
try:
694+
cmap = mpl.cm.get_cmap(palette_name)
695+
except (ValueError, KeyError):
696+
base_colors = list(DEFAULT_COLORS)
697+
cmap = None
698+
699+
if palette_name in SPECTRA_DISCRETE_PALETTES and cmap is not None:
700+
cmap_colors = getattr(cmap, "colors", None)
701+
if cmap_colors is None:
702+
cmap_colors = cmap(np.linspace(0.0, 1.0, cmap.N))
703+
base_colors = [mpl.colors.to_hex(color)
704+
for color in cmap_colors]
705+
elif cmap is not None:
706+
if requested_count == 0:
707+
return []
708+
positions = (np.array([0.5]) if requested_count == 1
709+
else np.linspace(0.0, 1.0, requested_count))
710+
return [mpl.colors.to_hex(cmap(position))
711+
for position in positions]
712+
713+
if not base_colors:
714+
base_colors = list(DEFAULT_COLORS) or ["#1f77b4"]
715+
return [base_colors[i % len(base_colors)]
716+
for i in range(requested_count)]
639717

640718
def _build_tensor_segments(self, x_shift_step, y_shift_step,
641719
plot_style, lw, fg_color, colors_cycle):
@@ -655,9 +733,15 @@ def _build_tensor_segments(self, x_shift_step, y_shift_step,
655733
Y_norm = self._get_normalized_y_tensor(x, Y)
656734

657735
# Resolve each spectrum's color: use stored color if available, else cycle
658-
t_colors = self._tensor_data.get("colors", [None] * N)
659-
main_colors = [c if c else colors_cycle[i % len(colors_cycle)]
660-
for i, c in enumerate(t_colors)]
736+
t_colors = self._tensor_data.get("colors")
737+
if t_colors is None:
738+
t_colors = []
739+
main_colors = [
740+
t_colors[i]
741+
if i < len(t_colors) and t_colors[i]
742+
else colors_cycle[i]
743+
for i in range(N)
744+
]
661745

662746
# tensor_list stores ragged arrays as a Python list; tensor stores a uniform 2-D ndarray
663747
is_list = isinstance(Y, list)
@@ -716,7 +800,7 @@ def _build_tensor_segments(self, x_shift_step, y_shift_step,
716800
t_labels = self._tensor_data.get("labels", [])
717801
t_fnames = self._tensor_data.get("fnames", [])
718802
proxies = self._tensor_data.get("proxies", []) # Spectrum objects for interactive tooltip
719-
for spec_idx in range(min(N, int(self.spin_max_overlays.value()))):
803+
for spec_idx in range(min(N, self.spin_max_legend_items.value())):
720804
spec_color = main_colors[spec_idx]
721805
# Priority: custom label > filename > generic fallback
722806
label_str = (
@@ -758,7 +842,8 @@ def _draw_tensor_overlays(self, x_shift_step, y_shift_step, lw,
758842
is_tensor_list = self._tensor_data.get("type") == "tensor_list"
759843
proxies = self._tensor_data.get("proxies", [])
760844

761-
for spec_idx in range(min(n_specs, int(self.spin_max_overlays.value()))):
845+
for spec_idx in range(
846+
min(n_specs, self.spin_max_legend_items.value())):
762847
x_val = self._tensor_data.get("x")
763848
x = x_val[spec_idx] if isinstance(x_val, list) else x_val
764849

@@ -1045,7 +1130,10 @@ def _finalize_plot(self, segments, plot_style, lw, dot_size, fg_color,
10451130

10461131
# ── Legend / axes / grid ──
10471132
if self.btn_legend.isChecked():
1048-
legend = self.ax.legend(loc="best")
1133+
handles, labels = self.ax.get_legend_handles_labels()
1134+
max_items = self.spin_max_legend_items.value()
1135+
legend = self.ax.legend(
1136+
handles[:max_items], labels[:max_items], loc="best")
10491137
self._make_legend_pickable(legend)
10501138

10511139
if self.act_grid.isChecked():
@@ -1099,7 +1187,11 @@ def _make_legend_pickable(self, legend):
10991187

11001188
# Cache legend and its artists for double-click hit-testing
11011189
self._legend_obj = legend
1102-
self._legend_bbox = legend.get_window_extent(self.canvas.renderer)
1190+
# ``renderer`` is only installed as an attribute after the first draw;
1191+
# get_renderer() also works when data arrives before the widget has
1192+
# been painted (common during workspace restoration and in tests).
1193+
self._legend_bbox = legend.get_window_extent(
1194+
self.canvas.get_renderer())
11031195

11041196
# Connect double-click handler once (replaces pick_event)
11051197
if not hasattr(self, "_legend_dblclick_connected"):
@@ -1256,6 +1348,7 @@ def get_options_state(self):
12561348
"yaxis": self.cbb_yaxis.currentText() if hasattr(self, "cbb_yaxis") else "",
12571349
"yscale": self.cbb_yscale.currentText() if hasattr(self, "cbb_yscale") else "Linear",
12581350
"plotstyle": self.cbb_plotstyle.currentText() if hasattr(self, "cbb_plotstyle") else "line",
1351+
"color_palette": self.cbb_color_palette.currentText() if hasattr(self, "cbb_color_palette") else "DEFAULT_COLORS",
12591352
"lw": self.spin_lw.value() if hasattr(self, "spin_lw") else 1.5,
12601353
"dotsize": self.spin_dotsize.value() if hasattr(self, "spin_dotsize") else 3.0,
12611354
"raw": self.act_raw.isChecked() if hasattr(self, "act_raw") else False,
@@ -1268,6 +1361,7 @@ def get_options_state(self):
12681361
"height": self.height_entry.text() if hasattr(self, "height_entry") else "4.0",
12691362
"legend": self.btn_legend.isChecked() if hasattr(self, "btn_legend") else False,
12701363
"bestfit": self.btn_bestfit.isChecked() if hasattr(self, "btn_bestfit") else False,
1364+
"max_legend_items": self.spin_max_legend_items.value() if hasattr(self, "spin_max_legend_items") else 15,
12711365
"copy_fig_theme": self.cbb_copy_theme.currentText() if hasattr(self, "cbb_copy_theme") else "Light Mode",
12721366
}
12731367

@@ -1291,6 +1385,10 @@ def _update(widget, setter, value):
12911385
_update(self.cbb_yaxis, self.cbb_yaxis.setCurrentText, state.get("yaxis"))
12921386
_update(self.cbb_yscale, self.cbb_yscale.setCurrentText, state.get("yscale"))
12931387
_update(self.cbb_plotstyle, self.cbb_plotstyle.setCurrentText, state.get("plotstyle"))
1388+
if hasattr(self, "cbb_color_palette"):
1389+
_update(self.cbb_color_palette,
1390+
self.cbb_color_palette.setCurrentText,
1391+
state.get("color_palette", "DEFAULT_COLORS"))
12941392
_update(self.spin_lw, self.spin_lw.setValue, state.get("lw"))
12951393
_update(self.spin_dotsize, self.spin_dotsize.setValue, state.get("dotsize"))
12961394
_update(self.act_raw, self.act_raw.setChecked, state.get("raw"))
@@ -1304,6 +1402,10 @@ def _update(widget, setter, value):
13041402
_update(self.height_entry, self.height_entry.setText, state.get("height"))
13051403
_update(self.btn_legend, self.btn_legend.setChecked, state.get("legend"))
13061404
_update(self.btn_bestfit, self.btn_bestfit.setChecked, state.get("bestfit"))
1405+
if hasattr(self, "spin_max_legend_items"):
1406+
_update(self.spin_max_legend_items,
1407+
self.spin_max_legend_items.setValue,
1408+
state.get("max_legend_items", 15))
13071409
if hasattr(self, "cbb_copy_theme"):
13081410
_update(self.cbb_copy_theme, self.cbb_copy_theme.setCurrentText, state.get("copy_fig_theme", "Light Mode"))
13091411

0 commit comments

Comments
 (0)