# ******************************************************************************
# pySimBlocks
# Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************
import copy
import numpy as np
import matplotlib.pyplot as plt
from dataclasses import dataclass, field
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QVBoxLayout,
QLabel, QTreeWidget, QTreeWidgetItem,
QPushButton, QSizePolicy, QMessageBox, QComboBox, QToolButton, QMenu, QCheckBox,
QSpinBox, QHeaderView, QLineEdit, QInputDialog,
)
from PySide6.QtCore import Qt
from matplotlib import gridspec
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
from matplotlib.figure import Figure
from pySimBlocks.gui.models.project_state import ProjectState
from pySimBlocks.core.config import PlotConfig
from pySimBlocks.project.plot_from_config import plot_from_config
from pySimBlocks.gui.dialogs.plot_series_style_dialog import SeriesStyleDialog
from pySimBlocks.gui.project_controller import ProjectController
from pySimBlocks.project.plot_series import (
DEFAULT_SERIES_STYLE,
SeriesStyle,
effective_style_for_component,
flatten_series,
is_manual_layout_plot,
manual_layout_to_plot_dict,
manual_state_from_layout_plot,
plot_step_series_styled,
resolve_series_style,
series_from_signal,
series_style_from_dict,
series_style_to_dict,
)
@dataclass
class _ManualLayoutSession:
"""Holds all mutable state for the multi-panel manual plot editor."""
selections: list[dict[str, set[str]]] = field(default_factory=lambda: [{}])
titles: list[str] = field(default_factory=lambda: ["Plot 1"])
active: int = 0
panel_styles: list[dict[str, SeriesStyle]] = field(default_factory=lambda: [{}])
@staticmethod
def default_title(index: int) -> str:
return f"Plot {index + 1}"
def ensure_titles_aligned(self) -> None:
while len(self.titles) < len(self.selections):
self.titles.append(self.default_title(len(self.titles)))
if len(self.titles) > len(self.selections):
self.titles = self.titles[: len(self.selections)]
def plot_title(self, index: int) -> str:
self.ensure_titles_aligned()
if 0 <= index < len(self.titles):
text = self.titles[index].strip()
if text:
return text
return self.default_title(index)
def save_title(self, index: int, text: str) -> None:
self.ensure_titles_aligned()
idx = min(index, len(self.titles) - 1)
self.titles[idx] = text
def clamp_active(self) -> None:
if not self.selections:
self.active = 0
return
self.active = min(self.active, len(self.selections) - 1)
def resize(self, value: int) -> None:
old_count = len(self.selections)
if value > old_count:
for i in range(old_count, value):
self.selections.append({})
self.titles.append(self.default_title(i))
self.panel_styles.append({})
elif value < old_count:
self.selections = self.selections[:value]
self.titles = self.titles[:value]
self.panel_styles = self.panel_styles[:value]
if self.active >= value:
self.active = max(0, value - 1)
def remove_at(self, idx: int) -> None:
del self.selections[idx]
del self.titles[idx]
del self.panel_styles[idx]
new_count = len(self.selections)
if self.active >= new_count:
self.active = max(0, new_count - 1)
def load_from_preset(self, plot: dict) -> None:
selections, titles, panel_styles = manual_state_from_layout_plot(plot)
if not selections:
self.selections = [{}]
self.titles = [self.default_title(0)]
self.panel_styles = [{}]
else:
self.selections = selections
self.titles = titles
self.panel_styles = panel_styles
while len(self.panel_styles) < len(self.selections):
self.panel_styles.append({})
self.panel_styles = self.panel_styles[: len(self.selections)]
self.active = 0
@dataclass
class _ModePresetSession:
"""Holds mutable state for mode-based preset editing (styles + working cache)."""
styles: dict[str, SeriesStyle] = field(default_factory=dict)
working: dict[int, dict] = field(default_factory=dict, init=False, repr=False)
def store(self, idx: int, signals: list[str], mode: str) -> None:
self.working[idx] = {
"signals": signals,
"mode": mode,
"styles": copy.deepcopy(self.styles),
}
def get_cached(self, idx: int) -> dict | None:
return self.working.get(idx)
def serialize_styles(self) -> dict[str, dict[str, str]]:
out: dict[str, dict[str, str]] = {}
for lbl, st in self.styles.items():
data = series_style_to_dict(st)
if data:
out[str(lbl)] = data
return out
@staticmethod
def styles_from_plot(plot: dict) -> dict[str, SeriesStyle]:
styles: dict[str, SeriesStyle] = {}
raw = plot.get("series_styles", {})
if isinstance(raw, dict):
for lbl, cfg in raw.items():
if isinstance(cfg, dict):
styles[str(lbl)] = series_style_from_dict(cfg)
return styles
[docs]
class PlotDialog(QDialog):
"""Preview logged signals and launch configured plot windows.
Attributes:
project_state: Project state providing logs and plot definitions.
signal tree selection: Checked signals/components for manual preview.
"""
def __init__(
self,
project_state: ProjectState,
project_controller: ProjectController | None = None,
parent=None,
):
"""Initialize the plot dialog.
Args:
project_state: Project state providing logs and plot definitions.
project_controller: Controller used to persist predefined plots.
parent: Optional parent widget.
Raises:
None.
"""
super().__init__(parent)
self.project_controller = project_controller
self.setWindowTitle("Plot signals")
self.resize(900, 500)
# fix fullscreen and minimize button not being available on certain environments
self.setWindowFlags(
Qt.Window
| Qt.WindowTitleHint
| Qt.WindowSystemMenuHint
| Qt.WindowMinimizeButtonHint
| Qt.WindowMaximizeButtonHint
| Qt.WindowCloseButtonHint
)
self.project_state = project_state
self._subplot_items: list[tuple[str, str]] = []
self._subplot_actions: dict[str, object] = {}
self._updating_signal_tree = False
self._focused_panel_key: str | None = None
self._axis_to_panel_key: dict[int, str] = {}
self._manual = _ManualLayoutSession()
self._mode = _ModePresetSession()
self._last_preset_index: int | None = None
self._build_ui()
self._populate_signals()
self._populate_plot_presets()
self._manual.clamp_active()
self._load_active_manual_title()
self._sync_manual_controls_enabled()
[docs]
def present(self) -> None:
"""Show this window and refresh the preview from the latest logs."""
self._flush_active_preset_working()
self._populate_signals()
self._populate_plot_presets()
if self._uses_manual_layout():
self._manual.clamp_active()
self._load_active_manual_title()
self._load_manual_selection_to_tree(
self._manual.selections[self._manual.active]
)
self._refresh_all_tree_style_labels()
elif self._is_mode_preset():
idx = self._selected_preset_index()
if idx is not None:
self._load_mode_preset(idx)
self._last_preset_index = self._selected_preset_index()
self._update_preview_plot()
self.show()
self.raise_()
self.activateWindow()
# --------------------------------------------------------------------------
# Private Methods
# --------------------------------------------------------------------------
@staticmethod
def _disable_enter_key_activation(button: QPushButton) -> None:
"""Prevent Enter in a nearby line edit from clicking this button."""
button.setAutoDefault(False)
button.setDefault(False)
def _build_ui(self):
"""Build the plot dialog user interface."""
main_layout = QHBoxLayout(self)
main_layout.addLayout(self._build_left_panel(), 0)
main_layout.addLayout(self._build_right_panel(), 1)
def _build_left_panel(self) -> QVBoxLayout:
"""Build and return the left control panel."""
left_layout = QVBoxLayout()
left_layout.addWidget(QLabel("<b>Manual plots</b>"))
manual_count_row = QHBoxLayout()
manual_count_row.addWidget(QLabel("Number of plots:"))
self.manual_plot_count_spin = QSpinBox()
self.manual_plot_count_spin.setMinimum(1)
self.manual_plot_count_spin.setMaximum(12)
self.manual_plot_count_spin.setValue(1)
self.manual_plot_count_spin.setToolTip(
"Number of manual plot panels. Decreasing the count removes the last plot only."
)
self.manual_plot_count_spin.valueChanged.connect(self._on_manual_plot_count_changed)
manual_count_row.addWidget(self.manual_plot_count_spin)
left_layout.addLayout(manual_count_row)
manual_edit_title_row = QHBoxLayout()
manual_edit_title_row.addWidget(QLabel("Title:"))
self.manual_plot_title_edit = QLineEdit()
self.manual_plot_title_edit.setPlaceholderText("Title of the selected plot panel")
self.manual_plot_title_edit.setToolTip(
"Click a plot panel in the preview to select it, then edit its title here."
)
self.manual_plot_title_edit.returnPressed.connect(self._on_manual_plot_title_edited)
self.manual_plot_title_edit.editingFinished.connect(self._on_manual_plot_title_edited)
manual_edit_title_row.addWidget(self.manual_plot_title_edit, 1)
left_layout.addLayout(manual_edit_title_row)
self.manual_remove_plot_btn = QPushButton("Remove selected plot")
self.manual_remove_plot_btn.setToolTip(
"Remove the plot panel selected in the preview and reduce the count by one."
)
self._disable_enter_key_activation(self.manual_remove_plot_btn)
self.manual_remove_plot_btn.clicked.connect(self._on_manual_remove_selected_plot)
left_layout.addWidget(self.manual_remove_plot_btn)
self.save_preset_btn = QPushButton("Save plot preset")
self.save_preset_btn.setToolTip(
"Save the current plot preset (manual layout or mode-based). "
"Overwrites the selected preset. Use Save project (Ctrl+S) for project.yaml."
)
self._disable_enter_key_activation(self.save_preset_btn)
self.save_preset_btn.clicked.connect(self._save_plot_preset)
left_layout.addWidget(self.save_preset_btn)
left_layout.addWidget(QLabel("<b>Signals (logged)</b>"))
self.signal_tree = QTreeWidget()
self.signal_tree.setColumnCount(2)
self.signal_tree.setHeaderLabels(["Signal", ""])
self.signal_tree.setColumnWidth(1, 34)
self.signal_tree.header().setStretchLastSection(False)
self.signal_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.signal_tree.itemChanged.connect(self._on_signal_toggled)
left_layout.addWidget(self.signal_tree)
left_layout.addWidget(QLabel("<b>Plot preset</b>"))
self.plot_preset_combo = QComboBox()
self.plot_preset_combo.currentIndexChanged.connect(self._on_plot_preset_changed)
left_layout.addWidget(self.plot_preset_combo)
mode_row = QHBoxLayout()
mode_row.addWidget(QLabel("Display mode:"))
self.mode_preset_combo = QComboBox()
for label, key in [
("Auto (recommended)", "auto"),
("Overlay (single axis)", "overlay"),
("Split by signal", "split_signals"),
("Split by component", "split_components"),
]:
self.mode_preset_combo.addItem(label, key)
self.mode_preset_combo.currentIndexChanged.connect(self._update_preview_plot)
mode_row.addWidget(self.mode_preset_combo, 1)
left_layout.addLayout(mode_row)
left_layout.addWidget(QLabel("<b>Subplots filter</b>"))
subplot_row = QHBoxLayout()
self.subplot_menu_btn = QToolButton()
self.subplot_menu_btn.setText("Select subplots")
self.subplot_menu_btn.setPopupMode(QToolButton.InstantPopup)
self.subplot_menu = QMenu(self.subplot_menu_btn)
self.subplot_menu_btn.setMenu(self.subplot_menu)
subplot_row.addWidget(self.subplot_menu_btn, 1)
self.subplot_all_cb = QCheckBox("All")
self.subplot_all_cb.setToolTip(
"Checked: every subplot in the filter is shown. Uncheck to hide all, or use the menu for a partial selection."
)
self.subplot_all_cb.stateChanged.connect(self._on_subplot_all_cb_changed)
self.subplot_all_cb.setEnabled(False)
subplot_row.addWidget(self.subplot_all_cb)
left_layout.addLayout(subplot_row)
self.plot_defined_btn = QPushButton("Plot defined plots")
self._disable_enter_key_activation(self.plot_defined_btn)
self.plot_defined_btn.clicked.connect(self._plot_defined_plots)
left_layout.addWidget(self.plot_defined_btn)
return left_layout
def _build_right_panel(self) -> QVBoxLayout:
"""Build and return the right plot preview panel."""
self.figure = Figure()
self.canvas = FigureCanvasQTAgg(self.figure)
self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.canvas.mpl_connect("button_press_event", self._on_canvas_click)
self.nav_toolbar = NavigationToolbar2QT(self.canvas, self)
right_layout = QVBoxLayout()
controls_layout = QHBoxLayout()
self.grid_cb = QCheckBox("Grid")
self.grid_cb.setChecked(True)
self.grid_cb.stateChanged.connect(self._update_preview_plot)
self.legend_cb = QCheckBox("Legend")
self.legend_cb.setChecked(True)
self.legend_cb.stateChanged.connect(self._update_preview_plot)
self.autoscale_btn = QPushButton("Autoscale")
self._disable_enter_key_activation(self.autoscale_btn)
self.autoscale_btn.clicked.connect(self._autoscale_preview)
controls_layout.addWidget(self.grid_cb)
controls_layout.addWidget(self.legend_cb)
controls_layout.addWidget(self.autoscale_btn)
controls_layout.addStretch(1)
right_layout.addLayout(controls_layout)
right_layout.addWidget(self.nav_toolbar)
right_layout.addWidget(self.canvas, 1)
return right_layout
def _populate_signals(self):
"""Populate the signal list from the available logged signals."""
self.signal_tree.clear()
self._updating_signal_tree = True
try:
for sig in sorted(self.project_state.logs.keys()):
if sig == "time":
continue
labels = self._component_labels_for_signal(sig)
if len(labels) == 1:
self._add_scalar_signal_tree_row(sig, labels[0])
continue
parent = QTreeWidgetItem([sig])
parent.setFlags(parent.flags() | Qt.ItemIsUserCheckable)
parent.setCheckState(0, Qt.Unchecked)
parent.setData(0, Qt.UserRole, ("signal", sig))
self.signal_tree.addTopLevelItem(parent)
for label in labels:
child = QTreeWidgetItem([self._tree_label_for_component(label), ""])
child.setFlags(child.flags() | Qt.ItemIsUserCheckable)
child.setCheckState(0, Qt.Unchecked)
child.setData(0, Qt.UserRole, ("component", sig, label))
parent.addChild(child)
self._attach_style_button(child, label)
finally:
self._updating_signal_tree = False
self._refresh_all_tree_style_labels()
def _add_scalar_signal_tree_row(self, sig: str, label: str) -> None:
"""Add one top-level row for a scalar signal (single component, no expand)."""
item = QTreeWidgetItem([self._tree_label_for_component(label), ""])
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(0, Qt.Unchecked)
item.setData(0, Qt.UserRole, ("component", sig, label))
self.signal_tree.addTopLevelItem(item)
self._attach_style_button(item, label)
def _get_series_style(self, label: str, plot: dict | None = None) -> SeriesStyle:
"""Return merged style for a series (session overrides, then plot config)."""
return resolve_series_style(label, self._mode.styles, plot)
def _get_manual_series_style(self, panel_idx: int, label: str) -> SeriesStyle:
"""Style for one component in a manual-layout panel (per subplot)."""
if 0 <= panel_idx < len(self._manual.panel_styles):
st = self._manual.panel_styles[panel_idx].get(label)
if st is not None:
return st
if self._is_manual_layout_preset():
idx = self._selected_preset_index()
if idx is not None:
plot = self.project_state.plots[idx]
return effective_style_for_component(plot, None, label)
return SeriesStyle()
def _tree_label_for_component(self, internal_label: str) -> str:
"""Text shown in the signal tree for one component."""
if self._uses_manual_layout():
name = self._get_manual_series_style(self._manual.active, internal_label).display_name.strip()
else:
plot = None
idx = self._selected_preset_index()
if idx is not None:
p = self.project_state.plots[idx]
if not is_manual_layout_plot(p):
plot = p
name = self._get_series_style(internal_label, plot).display_name.strip()
return name if name else internal_label
def _refresh_tree_component_label(self, internal_label: str) -> None:
"""Update the signal tree row after a display name change."""
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
data = item.data(0, Qt.UserRole)
if isinstance(data, tuple) and len(data) == 3 and data[0] == "component":
if str(data[2]) == internal_label:
item.setText(0, self._tree_label_for_component(internal_label))
return
continue
for c in range(item.childCount()):
child = item.child(c)
data = child.data(0, Qt.UserRole)
if isinstance(data, tuple) and len(data) == 3 and str(data[2]) == internal_label:
child.setText(0, self._tree_label_for_component(internal_label))
return
def _refresh_all_tree_style_labels(self) -> None:
"""Refresh visible names in the tree (needed when subplot context changes)."""
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
data = item.data(0, Qt.UserRole)
if isinstance(data, tuple) and len(data) == 3 and data[0] == "component":
lbl = str(data[2])
item.setText(0, self._tree_label_for_component(lbl))
continue
for c in range(item.childCount()):
child = item.child(c)
cdata = child.data(0, Qt.UserRole)
if isinstance(cdata, tuple) and len(cdata) == 3 and cdata[0] == "component":
lbl = str(cdata[2])
child.setText(0, self._tree_label_for_component(lbl))
def _attach_style_button(self, item: QTreeWidgetItem, label: str) -> None:
"""Add a style editor button on the right of a signal tree row."""
btn = QPushButton("⚙")
btn.setFixedSize(30, 22)
self._disable_enter_key_activation(btn)
btn.setToolTip(f"Line, marker, and color for « {label} »")
btn.clicked.connect(lambda _checked=False, lbl=label: self._edit_series_style(lbl))
self.signal_tree.setItemWidget(item, 1, btn)
def _edit_series_style(self, label: str) -> None:
"""Open the style dialog for one series component."""
if self._uses_manual_layout():
pidx = self._manual.active
while len(self._manual.panel_styles) <= pidx:
self._manual.panel_styles.append({})
current = self._manual.panel_styles[pidx].get(label, SeriesStyle())
else:
plot = None
idx = self._selected_preset_index()
if idx is not None:
p = self.project_state.plots[idx]
if not is_manual_layout_plot(p):
plot = p
current = self._get_series_style(label, plot)
dlg = SeriesStyleDialog(label, current, parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
if self._uses_manual_layout():
pidx = self._manual.active
self._manual.panel_styles[pidx][label] = dlg.style()
else:
self._mode.styles[label] = dlg.style()
if self._is_mode_preset():
idx = self._selected_preset_index()
if idx is not None:
self._store_mode_preset_working(idx)
self._refresh_tree_component_label(label)
self._update_preview_plot()
def _on_signal_toggled(self, _item: QTreeWidgetItem, _column: int):
"""Redraw preview when signal/component check states change."""
if self._updating_signal_tree:
return
self._updating_signal_tree = True
try:
data = _item.data(0, Qt.UserRole)
if isinstance(data, tuple) and len(data) >= 2 and data[0] == "signal":
# Parent toggled: apply state to all child components.
for i in range(_item.childCount()):
_item.child(i).setCheckState(0, _item.checkState(0))
elif isinstance(data, tuple) and len(data) == 3 and data[0] == "component":
# Child toggled: keep parent check state in sync.
parent = _item.parent()
if parent is not None:
checked = 0
for i in range(parent.childCount()):
if parent.child(i).checkState(0) == Qt.Checked:
checked += 1
if checked == 0:
parent.setCheckState(0, Qt.Unchecked)
elif checked == parent.childCount():
parent.setCheckState(0, Qt.Checked)
finally:
self._updating_signal_tree = False
if self._uses_manual_layout():
self._save_active_manual_selection()
elif self._is_mode_preset():
idx = self._selected_preset_index()
if idx is not None:
self._store_mode_preset_working(idx)
self._update_preview_plot()
def _on_plot_preset_changed(self, _index: int):
"""Handle plot preset selection changes."""
prev_idx = self._last_preset_index
if prev_idx is not None and prev_idx != self._selected_preset_index():
if 0 <= prev_idx < len(self.project_state.plots):
if is_manual_layout_plot(self.project_state.plots[prev_idx]):
self._save_active_manual_selection()
self._save_active_manual_title()
else:
self._store_mode_preset_working(prev_idx)
layout_preset = self._is_manual_layout_preset()
free_manual = self._is_manual_mode()
mode_preset = self._is_mode_preset()
self.signal_tree.setEnabled(free_manual or layout_preset or mode_preset)
self._sync_manual_controls_enabled()
self._sync_subplot_all_checkbox()
if layout_preset:
idx = self._selected_preset_index()
if idx is not None:
self._load_manual_state_from_layout_plot(self.project_state.plots[idx])
elif mode_preset:
idx = self._selected_preset_index()
if idx is not None:
self._load_mode_preset(idx)
elif free_manual:
self._load_manual_selection_to_tree(self._manual.selections[self._manual.active])
self._last_preset_index = self._selected_preset_index()
self._update_preview_plot()
def _is_manual_mode(self) -> bool:
"""Return True when editing a free manual layout (not a saved preset)."""
return self._selected_preset_index() is None
def _is_manual_layout_preset(self) -> bool:
"""Return True when the selected preset is a saved manual layout."""
idx = self._selected_preset_index()
if idx is None:
return False
return is_manual_layout_plot(self.project_state.plots[idx])
def _uses_manual_layout(self) -> bool:
"""Return True when the preview uses the multi-panel manual layout."""
return self._is_manual_mode() or self._is_manual_layout_preset()
def _is_mode_preset(self) -> bool:
"""Return True when the selected preset uses ``signals`` + ``mode``."""
idx = self._selected_preset_index()
if idx is None:
return False
return not is_manual_layout_plot(self.project_state.plots[idx])
def _sync_manual_controls_enabled(self) -> None:
"""Enable manual plot controls for free manual and layout presets."""
enabled = self._uses_manual_layout()
self.manual_plot_count_spin.setEnabled(enabled)
self.manual_plot_title_edit.setEnabled(enabled)
can_remove = enabled and self.manual_plot_count_spin.value() > 1
self.manual_remove_plot_btn.setEnabled(can_remove)
self.mode_preset_combo.setEnabled(self._is_mode_preset())
self.subplot_menu_btn.setEnabled(self._is_mode_preset())
can_save = self.project_controller is not None and (
self._uses_manual_layout() or self._is_mode_preset()
)
self.save_preset_btn.setEnabled(can_save)
def _save_active_manual_title(self) -> None:
"""Persist the title field into the active manual plot."""
if not self._manual.selections:
return
self._manual.save_title(self._manual.active, self.manual_plot_title_edit.text().strip())
def _load_active_manual_title(self) -> None:
"""Load the active manual plot title into the title field."""
if not self._manual.selections:
self.manual_plot_title_edit.clear()
return
self._manual.ensure_titles_aligned()
idx = min(self._manual.active, len(self._manual.titles) - 1)
self.manual_plot_title_edit.blockSignals(True)
self.manual_plot_title_edit.setText(self._manual.titles[idx])
self.manual_plot_title_edit.blockSignals(False)
def _on_manual_plot_title_edited(self) -> None:
"""Apply title edit and refresh preview."""
self._save_active_manual_title()
self._update_preview_plot()
def _on_manual_plot_count_changed(self, value: int) -> None:
"""Grow or shrink manual plots; decreasing removes only the last plot."""
self._save_active_manual_selection()
self._save_active_manual_title()
self._manual.resize(value)
self._manual.clamp_active()
self._sync_manual_controls_enabled()
self._load_active_manual_title()
self._load_manual_selection_to_tree(self._manual.selections[self._manual.active])
self._refresh_all_tree_style_labels()
self._update_preview_plot()
def _on_manual_remove_selected_plot(self) -> None:
"""Remove the plot currently selected for editing."""
if len(self._manual.selections) <= 1:
return
self._save_active_manual_title()
self._manual.remove_at(self._manual.active)
new_count = len(self._manual.selections)
self.manual_plot_count_spin.blockSignals(True)
self.manual_plot_count_spin.setValue(new_count)
self.manual_plot_count_spin.blockSignals(False)
self._manual.clamp_active()
self._sync_manual_controls_enabled()
self._load_active_manual_title()
self._load_manual_selection_to_tree(self._manual.selections[self._manual.active])
self._refresh_all_tree_style_labels()
self._update_preview_plot()
def _select_manual_plot(self, index: int) -> None:
"""Select a manual plot for editing (e.g. after clicking its axis)."""
if index < 0 or index >= len(self._manual.selections):
return
if index == self._manual.active:
self._update_preview_plot()
return
self._save_active_manual_selection()
self._save_active_manual_title()
self._manual.active = index
self._load_active_manual_title()
self._load_manual_selection_to_tree(self._manual.selections[index])
self._update_preview_plot()
def _save_active_manual_selection(self) -> None:
"""Persist the signal tree checks into the active manual plot."""
if not self._manual.selections:
return
idx = min(self._manual.active, len(self._manual.selections) - 1)
self._manual.active = idx
self._manual.selections[idx] = self._read_selection_from_signal_tree()
def _read_selection_from_signal_tree(self) -> dict[str, set[str]]:
"""Return selected component labels per signal from the tree widget."""
selected: dict[str, set[str]] = {}
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
item_data = item.data(0, Qt.UserRole)
if (
isinstance(item_data, tuple)
and len(item_data) == 3
and item_data[0] == "component"
):
if item.checkState(0) == Qt.Checked:
sig = str(item_data[1])
selected.setdefault(sig, set()).add(str(item_data[2]))
continue
if not isinstance(item_data, tuple) or len(item_data) < 2:
continue
sig = str(item_data[1])
labels: set[str] = set()
for c in range(item.childCount()):
child = item.child(c)
if child.checkState(0) != Qt.Checked:
continue
child_data = child.data(0, Qt.UserRole)
if isinstance(child_data, tuple) and len(child_data) == 3:
labels.add(str(child_data[2]))
if labels:
selected[sig] = labels
return selected
def _load_manual_selection_to_tree(self, selection: dict[str, set[str]]) -> None:
"""Apply a manual plot selection to the signal tree checkboxes."""
self._updating_signal_tree = True
try:
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
item_data = item.data(0, Qt.UserRole)
if (
isinstance(item_data, tuple)
and len(item_data) == 3
and item_data[0] == "component"
):
sig = str(item_data[1])
label = str(item_data[2])
checked = label in selection.get(sig, set())
item.setCheckState(
0, Qt.Checked if checked else Qt.Unchecked
)
continue
if not isinstance(item_data, tuple) or len(item_data) < 2:
continue
sig = str(item_data[1])
labels = selection.get(sig, set())
checked_count = 0
for c in range(item.childCount()):
child = item.child(c)
child_data = child.data(0, Qt.UserRole)
if not isinstance(child_data, tuple) or len(child_data) != 3:
continue
label = str(child_data[2])
if label in labels:
child.setCheckState(0, Qt.Checked)
checked_count += 1
else:
child.setCheckState(0, Qt.Unchecked)
n_children = item.childCount()
if checked_count == n_children and n_children > 0:
item.setCheckState(0, Qt.Checked)
else:
item.setCheckState(0, Qt.Unchecked)
finally:
self._updating_signal_tree = False
self._refresh_all_tree_style_labels()
def _on_subplot_toggled(self, _checked: bool):
"""Redraw preview when subplot filters change."""
self._update_subplot_button_text()
self._sync_subplot_all_checkbox()
self._update_preview_plot()
def _autoscale_preview(self):
"""Autoscale all preview axes."""
for ax in self.figure.axes:
ax.relim()
ax.autoscale_view()
self.canvas.draw_idle()
def _on_canvas_click(self, event):
"""Select manual plot on click; double-click toggles enlarged view."""
if event.inaxes is None:
return
key = self._axis_to_panel_key.get(id(event.inaxes))
if key is None:
return
if self._uses_manual_layout() and key.startswith("manual::"):
try:
plot_idx = int(key.split("::", 1)[1])
except ValueError:
return
if getattr(event, "dblclick", False):
self._select_manual_plot(plot_idx)
if self._focused_panel_key == key:
self._focused_panel_key = None
else:
self._focused_panel_key = key
self._update_preview_plot()
return
if self._focused_panel_key is not None:
self._focused_panel_key = None
self._update_preview_plot()
self._select_manual_plot(plot_idx)
return
if not getattr(event, "dblclick", False):
return
if self._focused_panel_key == key:
self._focused_panel_key = None
else:
self._focused_panel_key = key
self._update_preview_plot()
def _populate_plot_presets(self):
"""Populate the plot preset dropdown from project-defined plots."""
prev_data = self.plot_preset_combo.currentData()
self.plot_preset_combo.blockSignals(True)
self.plot_preset_combo.clear()
self.plot_preset_combo.addItem("Manual selection", None)
for idx, plot in enumerate(self.project_state.plots):
title = str(plot.get("title", f"Plot {idx + 1}"))
self.plot_preset_combo.addItem(title, idx)
target_idx = self.plot_preset_combo.findData(prev_data)
if target_idx < 0:
target_idx = 0
self.plot_preset_combo.setCurrentIndex(target_idx)
self.plot_preset_combo.blockSignals(False)
self._sync_manual_controls_enabled()
self._sync_subplot_all_checkbox()
def _selected_preset_index(self) -> int | None:
"""Return selected plot preset index or None for manual mode."""
data = self.plot_preset_combo.currentData()
if isinstance(data, int):
return data
return None
def _find_manual_preset_index_by_title(self, title: str) -> int | None:
"""Return index of a manual layout preset with ``title``, or None."""
key = title.strip()
if not key:
return None
for idx, plot in enumerate(self.project_state.plots):
if is_manual_layout_plot(plot) and str(plot.get("title", "")).strip() == key:
return idx
return None
def _component_labels_for_signal(self, sig: str) -> list[str]:
"""Return component labels for one signal."""
return [label for label, _ in series_from_signal(self.project_state.logs, sig)]
def _update_preview_plot(self):
"""Redraw the embedded preview plot from the selected signals."""
self.figure.clear()
preset_index = self._selected_preset_index()
if self._uses_manual_layout():
if self._is_manual_mode():
sel = self._read_selection_from_signal_tree()
# Avoid wiping panel data when the tree was rebuilt but not restored yet.
if sel or not (
self._manual.selections
and self._manual.selections[
min(self._manual.active, len(self._manual.selections) - 1)
]
):
self._save_active_manual_selection()
self._render_manual_plots_preview()
self.canvas.draw()
return
preset_plot = self.project_state.plots[preset_index]
active_signals = sorted(self._read_mode_signals_from_tree())
if not active_signals:
self._refresh_subplot_filter([], keep_current=False)
self.canvas.draw()
return
time = np.asarray(self.project_state.logs["time"]).flatten()
T = len(time)
try:
series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]] = []
for sig in active_signals:
sig_series = series_from_signal(self.project_state.logs, sig)
n_samples = len(sig_series[0][1]) if sig_series else 0
if n_samples != T:
raise ValueError(
f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}."
)
series_by_signal.append((sig, sig_series))
flat_series = flatten_series(series_by_signal)
mode = self._resolve_defined_mode(
{"mode": self._current_mode_preset_mode()},
flat_series,
series_by_signal,
)
panels = self._build_panels_for_mode(mode, series_by_signal, flat_series)
self._refresh_subplot_filter(panels, keep_current=True)
enabled_panel_keys = self._selected_subplot_keys()
panels = [panel for panel in panels if panel[1] in enabled_panel_keys]
self._render_panels(time, panels, plot=preset_plot)
self._finalize_layout()
except Exception as e:
ax = self.figure.add_subplot(111)
# Keep the UI responsive; show the error inside the plot area.
ax.text(
0.01, 0.99,
f"Plot preview error:\n{e}",
transform=ax.transAxes,
va="top",
ha="left",
wrap=True,
)
ax.set_axis_off()
self.canvas.draw()
def _series_from_manual_selection(
self, selection: dict[str, set[str]], time: np.ndarray
) -> list[tuple[str, np.ndarray]]:
"""Build (label, values) series for one manual plot selection."""
T = len(time)
series: list[tuple[str, np.ndarray]] = []
for sig in sorted(selection.keys()):
sig_series = series_from_signal(self.project_state.logs, sig)
n_samples = len(sig_series[0][1]) if sig_series else 0
if n_samples != T:
raise ValueError(
f"Time length mismatch for '{sig}': time has {T} samples but signal has {n_samples}."
)
selected_labels = selection[sig]
for label, values in sig_series:
if label in selected_labels:
series.append((label, values))
return series
def _draw_manual_panel(
self,
ax,
time: np.ndarray,
title: str,
key: str,
series: list[tuple[str, np.ndarray]],
panel_idx: int,
) -> None:
"""Draw one manual plot panel and register it for hit-testing."""
if series:
for label, values in series:
plot_step_series_styled(
ax,
time,
values,
label,
self._get_manual_series_style(panel_idx, label),
)
else:
ax.text(
0.5,
0.5,
"No signals selected.",
transform=ax.transAxes,
ha="center",
va="center",
)
ax.set_title(title)
self._style_axis(ax)
self._axis_to_panel_key[id(ax)] = key
self._highlight_manual_axis(ax, key == f"manual::{self._manual.active}")
def _render_manual_plots_preview(self) -> None:
"""Render all manual plot panels in a 2-column grid (last odd spans full width)."""
self._refresh_subplot_filter([], keep_current=False)
self._axis_to_panel_key.clear()
n_plots = len(self._manual.selections)
if n_plots == 0:
return
time = np.asarray(self.project_state.logs["time"]).flatten()
panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]] = []
try:
for i, selection in enumerate(self._manual.selections):
series = self._series_from_manual_selection(selection, time)
title = self._manual.plot_title(i)
panels.append((title, f"manual::{i}", series))
except Exception as e:
ax = self.figure.add_subplot(111)
ax.text(
0.01,
0.99,
f"Plot preview error:\n{e}",
transform=ax.transAxes,
va="top",
ha="left",
wrap=True,
)
ax.set_axis_off()
return
if self._focused_panel_key is not None:
existing_keys = {key for _, key, _ in panels}
if self._focused_panel_key in existing_keys:
panels = [p for p in panels if p[1] == self._focused_panel_key]
else:
self._focused_panel_key = None
if len(panels) == 1:
title, key, series = panels[0]
ax = self.figure.add_subplot(111)
self._draw_manual_panel(ax, time, title, key, series, 0)
self._finalize_layout()
return
n_rows = (n_plots + 1) // 2
gs = gridspec.GridSpec(n_rows, 2, figure=self.figure)
for i, (title, key, series) in enumerate(panels):
if i == n_plots - 1 and n_plots % 2 == 1:
ax = self.figure.add_subplot(gs[i // 2, :])
else:
ax = self.figure.add_subplot(gs[i // 2, i % 2])
self._draw_manual_panel(ax, time, title, key, series, i)
self._finalize_layout()
def _highlight_manual_axis(self, ax, selected: bool) -> None:
"""Visually mark the manual plot currently being edited."""
width = 2.5 if selected else 0.8
color = "#1f77b4" if selected else "0.8"
for spine in ax.spines.values():
spine.set_linewidth(width)
spine.set_edgecolor(color)
def _refresh_subplot_filter(self, panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]], keep_current: bool):
"""Rebuild subplot check-list from panel descriptors."""
current_enabled = self._selected_subplot_keys() if keep_current else set()
new_items = [(title, key) for title, key, _ in panels]
if new_items == self._subplot_items and keep_current:
return
self._subplot_items = new_items
self._subplot_actions.clear()
self.subplot_menu.clear()
for title, key in new_items:
checked = (key in current_enabled) if keep_current else True
action = self.subplot_menu.addAction(title)
action.setCheckable(True)
action.setChecked(checked)
action.toggled.connect(self._on_subplot_toggled)
self._subplot_actions[key] = action
self._update_subplot_button_text()
self._sync_subplot_all_checkbox()
def _on_subplot_all_cb_changed(self, *_args: object) -> None:
"""Apply or clear all subplot menu checks from the All checkbox."""
if not self._subplot_actions:
return
check_all = self.subplot_all_cb.isChecked()
for action in self._subplot_actions.values():
action.blockSignals(True)
action.setChecked(check_all)
action.blockSignals(False)
self._update_subplot_button_text()
self._update_preview_plot()
def _sync_subplot_all_checkbox(self) -> None:
"""Enable the All checkbox and mirror whether every subplot action is checked."""
self.subplot_all_cb.blockSignals(True)
try:
enabled = self.subplot_menu_btn.isEnabled() and len(self._subplot_actions) > 0
self.subplot_all_cb.setEnabled(enabled)
if not enabled or not self._subplot_actions:
self.subplot_all_cb.setChecked(False)
else:
all_on = all(action.isChecked() for action in self._subplot_actions.values())
self.subplot_all_cb.setChecked(all_on)
finally:
self.subplot_all_cb.blockSignals(False)
def _selected_subplot_keys(self) -> set[str]:
"""Return selected subplot panel keys."""
if not self._subplot_actions:
return set()
keys: set[str] = set()
for key, action in self._subplot_actions.items():
if action.isChecked():
if isinstance(key, str):
keys.add(key)
return keys
def _update_subplot_button_text(self) -> None:
"""Update dropdown button label with current selection count."""
total = len(self._subplot_actions)
if total == 0:
self.subplot_menu_btn.setText("Select subplots")
return
selected = len(self._selected_subplot_keys())
self.subplot_menu_btn.setText(f"Subplots: {selected}/{total}")
def _resolve_defined_mode(
self,
plot: dict,
flat_series: list[tuple[str, str, np.ndarray]],
series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]],
) -> str:
"""Resolve mode for a predefined plot using same defaults as project plots."""
mode = str(plot.get("mode", "auto")).strip().lower()
if mode in {"overlay", "split_signals", "split_components"}:
return mode
if mode != "auto":
return "overlay"
if len(flat_series) > 6:
return "split_components"
if len(series_by_signal) > 2:
return "split_signals"
return "overlay"
def _build_panels_for_mode(
self,
mode: str,
series_by_signal: list[tuple[str, list[tuple[str, np.ndarray]]]],
flat_series: list[tuple[str, str, np.ndarray]],
) -> list[tuple[str, str, list[tuple[str, np.ndarray]]]]:
"""Build panel descriptors: (title, key, series list)."""
if mode == "overlay":
panel_series = [(label, values) for _, label, values in flat_series]
return [("Overlay", "overlay::all", panel_series)]
if mode == "split_signals":
panels = []
for sig, sig_series in series_by_signal:
panels.append((sig, f"sig::{sig}", sig_series))
return panels
panels = []
for _, label, values in flat_series:
panels.append((label, f"cmp::{label}", [(label, values)]))
return panels
def _load_manual_state_from_layout_plot(self, plot: dict) -> None:
"""Restore manual panels, titles, and styles from a layout preset."""
self._manual.load_from_preset(plot)
self.manual_plot_count_spin.blockSignals(True)
self.manual_plot_count_spin.setValue(len(self._manual.selections))
self.manual_plot_count_spin.blockSignals(False)
self._manual.clamp_active()
self._load_active_manual_title()
self._load_manual_selection_to_tree(self._manual.selections[self._manual.active])
def _flush_active_preset_working(self) -> None:
"""Persist in-memory edits for the active preset before rebuilding the UI."""
idx = self._selected_preset_index()
if idx is None:
return
if is_manual_layout_plot(self.project_state.plots[idx]):
self._save_active_manual_selection()
self._save_active_manual_title()
else:
self._store_mode_preset_working(idx)
def _current_mode_preset_mode(self) -> str:
data = self.mode_preset_combo.currentData()
if isinstance(data, str):
return data
return "auto"
def _set_mode_preset_combo(self, mode: str) -> None:
idx = self.mode_preset_combo.findData(mode)
if idx < 0:
idx = self.mode_preset_combo.findData("auto")
if idx < 0:
idx = 0
self.mode_preset_combo.blockSignals(True)
self.mode_preset_combo.setCurrentIndex(idx)
self.mode_preset_combo.blockSignals(False)
def _read_mode_signals_from_tree(self) -> list[str]:
"""Return top-level signal names checked for a mode-based preset."""
selected: list[str] = []
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
data = item.data(0, Qt.UserRole)
if not isinstance(data, tuple) or len(data) < 2:
continue
if data[0] == "signal" and item.checkState(0) == Qt.Checked:
selected.append(str(data[1]))
elif data[0] == "component" and item.checkState(0) == Qt.Checked:
selected.append(str(data[1]))
return sorted(set(selected))
def _load_mode_signals_to_tree(self, signals: set[str]) -> None:
"""Apply mode-preset signal selection to the signal tree."""
self._updating_signal_tree = True
try:
for i in range(self.signal_tree.topLevelItemCount()):
item = self.signal_tree.topLevelItem(i)
data = item.data(0, Qt.UserRole)
if isinstance(data, tuple) and len(data) == 3 and data[0] == "component":
sig = str(data[1])
checked = sig in signals
item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked)
continue
if not isinstance(data, tuple) or len(data) < 2 or data[0] != "signal":
continue
sig = str(data[1])
checked_count = 0
for c in range(item.childCount()):
child = item.child(c)
child_data = child.data(0, Qt.UserRole)
if not isinstance(child_data, tuple) or len(child_data) != 3:
continue
if sig in signals:
child.setCheckState(0, Qt.Checked)
checked_count += 1
else:
child.setCheckState(0, Qt.Unchecked)
n_children = item.childCount()
if n_children > 0:
if checked_count == n_children and n_children > 0:
item.setCheckState(0, Qt.Checked)
else:
item.setCheckState(0, Qt.Unchecked)
else:
item.setCheckState(0, Qt.Checked if sig in signals else Qt.Unchecked)
finally:
self._updating_signal_tree = False
def _store_mode_preset_working(self, idx: int) -> None:
self._mode.store(idx, self._read_mode_signals_from_tree(), self._current_mode_preset_mode())
def _load_mode_preset(self, idx: int) -> None:
"""Restore a mode preset from session cache or project YAML."""
cached = self._mode.get_cached(idx)
if isinstance(cached, dict):
signals = [str(s) for s in cached.get("signals", []) if s]
mode = str(cached.get("mode", "auto"))
raw_styles = cached.get("styles", {})
self._mode.styles = copy.deepcopy(raw_styles) if isinstance(raw_styles, dict) else {}
else:
plot = self.project_state.plots[idx]
signals = [str(s) for s in plot.get("signals", []) if s]
mode = str(plot.get("mode", "auto"))
self._mode.styles = _ModePresetSession.styles_from_plot(plot)
self._set_mode_preset_combo(mode)
self._load_mode_signals_to_tree(set(signals))
def _save_plot_preset(self) -> None:
"""Save the active manual or mode-based plot preset."""
if self.project_controller is None:
return
if self._uses_manual_layout():
self._save_manual_as_plot_preset()
elif self._is_mode_preset():
self._save_mode_as_plot_preset()
def _save_mode_as_plot_preset(self) -> None:
"""Write the edited mode-based preset into the project."""
idx = self._selected_preset_index()
if idx is None or self.project_controller is None:
return
self._store_mode_preset_working(idx)
cached = self._mode.get_cached(idx) or {}
signals = list(cached.get("signals", []))
if not signals:
QMessageBox.warning(
self,
"Nothing to save",
"No signal selected.\nCheck at least one signal.",
)
return
mode = str(cached.get("mode", "auto"))
title = str(self.project_state.plots[idx].get("title", "Plot"))
self.project_controller.update_plot(
idx,
title,
signals,
mode=mode,
series_styles=self._mode.serialize_styles(),
)
QMessageBox.information(
self,
"Plot preset saved",
f"Preset « {title} » updated.\n"
"Save the project (Ctrl+S) to write it to project.yaml.",
)
def _save_manual_as_plot_preset(self) -> None:
"""Save or overwrite a manual layout plot preset."""
if self.project_controller is None or not self._uses_manual_layout():
return
self._save_active_manual_selection()
self._save_active_manual_title()
self._manual.ensure_titles_aligned()
editing_idx = (
self._selected_preset_index() if self._is_manual_layout_preset() else None
)
if editing_idx is not None:
preset_name = str(
self.project_state.plots[editing_idx].get("title", "Manual layout")
).strip() or "Manual layout"
else:
default_name = (
self._manual.plot_title(0) if self._manual.titles else "Manual layout"
)
name, ok = QInputDialog.getText(
self,
"Save plot preset",
"Preset name:",
text=default_name,
)
if not ok or not name.strip():
return
preset_name = name.strip()
plot_dict = manual_layout_to_plot_dict(
preset_name,
self._manual.titles,
self._manual.selections,
self._manual.panel_styles,
)
if plot_dict is None:
QMessageBox.warning(
self,
"Nothing to save",
"No plot panel has any signal selected.\n"
"Check signals for at least one panel.",
)
return
target_idx = editing_idx
if target_idx is None:
target_idx = self._find_manual_preset_index_by_title(preset_name)
created = target_idx is None
if target_idx is None:
target_idx = self.project_controller.add_manual_layout_preset(plot_dict)
else:
self.project_controller.update_manual_layout_preset(target_idx, plot_dict)
self._populate_plot_presets()
combo_idx = self.plot_preset_combo.findData(target_idx)
self.plot_preset_combo.blockSignals(True)
if combo_idx >= 0:
self.plot_preset_combo.setCurrentIndex(combo_idx)
self.plot_preset_combo.blockSignals(False)
self._on_plot_preset_changed(self.plot_preset_combo.currentIndex())
action = "added" if created else "updated"
QMessageBox.information(
self,
"Plot preset saved",
f"Preset « {preset_name} » {action} "
f"({len(plot_dict.get('panels', []))} panels).\n"
"Save the project (Ctrl+S) to write it to project.yaml.",
)
def _render_panels(
self,
time: np.ndarray,
panels: list[tuple[str, str, list[tuple[str, np.ndarray]]]],
plot: dict | None = None,
) -> None:
"""Render selected panels on one or multiple subplots."""
self._axis_to_panel_key.clear()
if self._focused_panel_key is not None:
existing_keys = {key for _, key, _ in panels}
if self._focused_panel_key in existing_keys:
panels = [p for p in panels if p[1] == self._focused_panel_key]
else:
self._focused_panel_key = None
if not panels:
ax = self.figure.add_subplot(111)
ax.text(
0.01,
0.99,
"No subplot selected.",
transform=ax.transAxes,
va="top",
ha="left",
)
ax.set_axis_off()
return
if len(panels) == 1:
ax = self.figure.add_subplot(111)
title, key, series = panels[0]
for label, values in series:
plot_step_series_styled(ax, time, values, label, self._get_series_style(label, plot))
ax.set_title(title)
self._style_axis(ax)
self._axis_to_panel_key[id(ax)] = key
return
rows, cols = self._panel_grid_shape(len(panels))
share_x = cols == 1
axes_grid = self.figure.subplots(rows, cols, sharex=share_x, squeeze=False)
axes = axes_grid.flatten()
for i, (title, key, series) in enumerate(panels):
ax = axes[i]
for label, values in series:
plot_step_series_styled(ax, time, values, label, self._get_series_style(label, plot))
ax.set_title(title)
self._style_axis(ax)
self._axis_to_panel_key[id(ax)] = key
# Hide unused cells when panel count does not fill the grid.
for j in range(len(panels), len(axes)):
axes[j].set_axis_off()
def _style_axis(self, ax) -> None:
"""Apply user-selected axis style."""
ax.set_xlabel("Time [s]")
ax.grid(self.grid_cb.isChecked())
if self.legend_cb.isChecked() and ax.lines:
ax.legend()
def _finalize_layout(self) -> None:
"""Apply a robust layout for many stacked axes."""
n_axes = len(self.figure.axes)
if n_axes >= 8:
self.figure.subplots_adjust(hspace=0.75)
return
self.figure.tight_layout()
def _panel_grid_shape(self, n_panels: int) -> tuple[int, int]:
"""Return (rows, cols) layout for panel rendering."""
if n_panels <= 1:
return 1, 1
if n_panels == 2:
return 2, 1
if n_panels == 3:
return 3, 1
if n_panels == 4:
return 2, 2
rows = int(np.ceil(n_panels / 2))
return rows, 2
def _plot_defined_plots(self):
"""Open standalone matplotlib windows for the configured plots."""
if not self.project_state.plots:
QMessageBox.information(
self,
"No plots defined",
"No plots are defined in the project settings."
)
return
plot_from_config(
logs=self.project_state.logs,
plot_cfg=PlotConfig(self.project_state.plots),
show=True,
block=False
)