toucan-plot 0.2.0__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.
- toucan_plot/__main__.py +4 -0
- toucan_plot/assets/adjustments-dark.svg +1 -0
- toucan_plot/assets/adjustments-light.svg +1 -0
- toucan_plot/assets/arrow-autofit-height-dark.svg +1 -0
- toucan_plot/assets/arrow-autofit-height-light.svg +1 -0
- toucan_plot/assets/arrow-narrow-left-dark.svg +1 -0
- toucan_plot/assets/arrow-narrow-left-light.svg +1 -0
- toucan_plot/assets/arrow-narrow-right-dark.svg +1 -0
- toucan_plot/assets/arrow-narrow-right-light.svg +1 -0
- toucan_plot/assets/device-floppy-dark.svg +1 -0
- toucan_plot/assets/device-floppy-light.svg +1 -0
- toucan_plot/assets/hand-stop-dark.svg +1 -0
- toucan_plot/assets/hand-stop-light.svg +1 -0
- toucan_plot/assets/home-dark.svg +1 -0
- toucan_plot/assets/home-light.svg +1 -0
- toucan_plot/assets/ico/toucan-plot.ico +0 -0
- toucan_plot/assets/layout-grid-add-dark.svg +1 -0
- toucan_plot/assets/layout-grid-add-light.svg +1 -0
- toucan_plot/assets/math-function-dark.svg +1 -0
- toucan_plot/assets/math-function-light.svg +1 -0
- toucan_plot/assets/ruler-measure-dark.svg +1 -0
- toucan_plot/assets/ruler-measure-light.svg +1 -0
- toucan_plot/assets/zoom-dark.svg +1 -0
- toucan_plot/assets/zoom-light.svg +1 -0
- toucan_plot/main.py +2047 -0
- toucan_plot/utils/__init__.py +14 -0
- toucan_plot/utils/loaders.py +219 -0
- toucan_plot/utils/styles.py +55 -0
- toucan_plot-0.2.0.dist-info/METADATA +16 -0
- toucan_plot-0.2.0.dist-info/RECORD +34 -0
- toucan_plot-0.2.0.dist-info/WHEEL +5 -0
- toucan_plot-0.2.0.dist-info/entry_points.txt +2 -0
- toucan_plot-0.2.0.dist-info/licenses/LICENSE +21 -0
- toucan_plot-0.2.0.dist-info/top_level.txt +1 -0
toucan_plot/main.py
ADDED
|
@@ -0,0 +1,2047 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import multiprocessing
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PyQt6 import QtWidgets, QtCore, QtGui
|
|
6
|
+
import matplotlib as mpl
|
|
7
|
+
from matplotlib.figure import Figure
|
|
8
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
9
|
+
from matplotlib.backends.backend_qt import NavigationToolbar2QT as NavigationToolbar
|
|
10
|
+
import qdarktheme
|
|
11
|
+
|
|
12
|
+
from toucan_plot.utils import styles, csv_load_worker, can_log_load_worker, mf4_load_worker
|
|
13
|
+
|
|
14
|
+
LEGEND_POSITIONS = [
|
|
15
|
+
'best', 'upper right', 'upper left', 'lower left', 'lower right',
|
|
16
|
+
'center left', 'center right', 'upper center', 'lower center', 'center',
|
|
17
|
+
'outside right', 'outside top',
|
|
18
|
+
'outside upper right', 'outside lower right',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
class MainWindow(QtWidgets.QMainWindow):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.setWindowTitle('Toucan-Plot')
|
|
25
|
+
self.setWindowIcon(QtGui.QIcon(os.path.join(os.path.dirname(__file__), 'assets', 'ico', 'toucan-plot.ico')))
|
|
26
|
+
self.resize(900, 600)
|
|
27
|
+
|
|
28
|
+
# central widget and layout
|
|
29
|
+
central = QtWidgets.QWidget()
|
|
30
|
+
self.setCentralWidget(central)
|
|
31
|
+
layout = QtWidgets.QHBoxLayout(central)
|
|
32
|
+
|
|
33
|
+
# Matplotlib canvas
|
|
34
|
+
self.fig = Figure(figsize=(6, 4), dpi=100, facecolor='white')
|
|
35
|
+
self.canvas = FigureCanvas(self.fig)
|
|
36
|
+
self.ax = self.fig.add_subplot(111)
|
|
37
|
+
# canvas will be placed inside a vertical container together with the full NavigationToolbar
|
|
38
|
+
|
|
39
|
+
# Menu bar: add an action to create a new subplot from a chosen series
|
|
40
|
+
menubar = self.menuBar()
|
|
41
|
+
if menubar is not None:
|
|
42
|
+
file_menu = menubar.addMenu('File')
|
|
43
|
+
open_action = QtGui.QAction('Open', self)
|
|
44
|
+
open_action.setShortcut('Ctrl+O')
|
|
45
|
+
open_action.triggered.connect(self.open_file)
|
|
46
|
+
self._merge_action = QtGui.QAction('Merge', self)
|
|
47
|
+
self._merge_action.setShortcut('Ctrl+M')
|
|
48
|
+
self._merge_action.setEnabled(False)
|
|
49
|
+
self._merge_action.triggered.connect(self.merge_file)
|
|
50
|
+
exit_action = QtGui.QAction('Exit', self)
|
|
51
|
+
exit_action.triggered.connect(self.close)
|
|
52
|
+
if file_menu is not None:
|
|
53
|
+
file_menu.addAction(open_action)
|
|
54
|
+
file_menu.addAction(self._merge_action)
|
|
55
|
+
file_menu.addSeparator()
|
|
56
|
+
file_menu.addAction(exit_action)
|
|
57
|
+
|
|
58
|
+
style_menu = menubar.addMenu('Style')
|
|
59
|
+
style_action = QtGui.QAction('Customize plot style', self)
|
|
60
|
+
style_action.triggered.connect(self.show_plot_style_dialog)
|
|
61
|
+
if style_menu is not None:
|
|
62
|
+
style_menu.addAction(style_action)
|
|
63
|
+
|
|
64
|
+
# Theme submenu
|
|
65
|
+
theme_menu = style_menu.addMenu('Theme')
|
|
66
|
+
self._theme_actions = {}
|
|
67
|
+
theme_group = QtGui.QActionGroup(theme_menu)
|
|
68
|
+
for theme_name in ('dark', 'light'):
|
|
69
|
+
a = QtGui.QAction(theme_name.capitalize(), self)
|
|
70
|
+
a.setCheckable(True)
|
|
71
|
+
a.setChecked(theme_name == 'dark')
|
|
72
|
+
theme_group.addAction(a)
|
|
73
|
+
theme_menu.addAction(a)
|
|
74
|
+
self._theme_actions[theme_name] = a
|
|
75
|
+
def make_theme_handler(t):
|
|
76
|
+
def handler():
|
|
77
|
+
self._apply_theme(t)
|
|
78
|
+
return handler
|
|
79
|
+
a.triggered.connect(make_theme_handler(theme_name))
|
|
80
|
+
|
|
81
|
+
# Icon mappings for toolbar buttons (keyed by theme variant)
|
|
82
|
+
assets_dir = os.path.join(os.path.dirname(__file__), 'assets')
|
|
83
|
+
self._icons_buttons = {
|
|
84
|
+
"Home": { "dark": os.path.join(assets_dir, 'home-dark.svg'),
|
|
85
|
+
"light": os.path.join(assets_dir, 'home-light.svg') },
|
|
86
|
+
"Pan": { "dark": os.path.join(assets_dir, 'hand-stop-dark.svg'),
|
|
87
|
+
"light": os.path.join(assets_dir, 'hand-stop-light.svg') },
|
|
88
|
+
"Zoom": { "dark": os.path.join(assets_dir, 'zoom-dark.svg'),
|
|
89
|
+
"light": os.path.join(assets_dir, 'zoom-light.svg') },
|
|
90
|
+
"Customize": { "dark": os.path.join(assets_dir, 'adjustments-dark.svg'),
|
|
91
|
+
"light": os.path.join(assets_dir, 'adjustments-light.svg') },
|
|
92
|
+
"Back": { "dark": os.path.join(assets_dir, 'arrow-narrow-left-dark.svg'),
|
|
93
|
+
"light": os.path.join(assets_dir, 'arrow-narrow-left-light.svg') },
|
|
94
|
+
"Forward": { "dark": os.path.join(assets_dir, 'arrow-narrow-right-dark.svg'),
|
|
95
|
+
"light": os.path.join(assets_dir, 'arrow-narrow-right-light.svg') },
|
|
96
|
+
"Save": { "dark": os.path.join(assets_dir, 'device-floppy-dark.svg'),
|
|
97
|
+
"light": os.path.join(assets_dir, 'device-floppy-light.svg') },
|
|
98
|
+
}
|
|
99
|
+
self._extra_btn_icons = {
|
|
100
|
+
'add_subplot': { "dark": os.path.join(assets_dir, 'layout-grid-add-dark.svg'),
|
|
101
|
+
"light": os.path.join(assets_dir, 'layout-grid-add-light.svg') },
|
|
102
|
+
'fit_y': { "dark": os.path.join(assets_dir, 'arrow-autofit-height-dark.svg'),
|
|
103
|
+
"light": os.path.join(assets_dir, 'arrow-autofit-height-light.svg') },
|
|
104
|
+
'measure': { "dark": os.path.join(assets_dir, 'ruler-measure-dark.svg'),
|
|
105
|
+
"light": os.path.join(assets_dir, 'ruler-measure-light.svg') },
|
|
106
|
+
'x_axis': { "dark": os.path.join(assets_dir, 'math-function-dark.svg'),
|
|
107
|
+
"light": os.path.join(assets_dir, 'math-function-light.svg') },
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Current theme ("dark" or "light")
|
|
111
|
+
self._current_theme = 'light'
|
|
112
|
+
|
|
113
|
+
# Put canvas and the full Matplotlib navigation toolbar in a vertical widget
|
|
114
|
+
unwanted_buttons = ["Subplots"]
|
|
115
|
+
plot_widget = QtWidgets.QWidget()
|
|
116
|
+
self.nav_toolbar = NavigationToolbar(self.canvas, self, coordinates=False)
|
|
117
|
+
# Override edit_parameters so update_plot runs after the Customize dialog closes
|
|
118
|
+
_orig_edit_parameters = self.nav_toolbar.edit_parameters
|
|
119
|
+
def _custom_edit_parameters():
|
|
120
|
+
_orig_edit_parameters()
|
|
121
|
+
self.update_plot()
|
|
122
|
+
self.nav_toolbar.edit_parameters = _custom_edit_parameters
|
|
123
|
+
# Override the Home button to auto-fit all series data
|
|
124
|
+
for action in self.nav_toolbar.actions():
|
|
125
|
+
if action.text() in unwanted_buttons:
|
|
126
|
+
self.nav_toolbar.removeAction(action)
|
|
127
|
+
continue
|
|
128
|
+
if action.text() in self._icons_buttons:
|
|
129
|
+
icon_path = self._icons_buttons[action.text()][self._current_theme]
|
|
130
|
+
if os.path.exists(icon_path):
|
|
131
|
+
action.setIcon(QtGui.QIcon(icon_path))
|
|
132
|
+
if action.text() == 'Home':
|
|
133
|
+
action.triggered.disconnect()
|
|
134
|
+
action.triggered.connect(self._home_autoscale)
|
|
135
|
+
|
|
136
|
+
# Add subplot button next to the navigation toolbar
|
|
137
|
+
self._add_subplot_btn = QtWidgets.QToolButton()
|
|
138
|
+
self._add_subplot_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['add_subplot'][self._current_theme]))
|
|
139
|
+
self._add_subplot_btn.setIconSize(QtCore.QSize(24, 24))
|
|
140
|
+
self._add_subplot_btn.setToolTip('Add subplot')
|
|
141
|
+
self._add_subplot_btn.clicked.connect(self.show_series_selector)
|
|
142
|
+
|
|
143
|
+
# Fit Y axis button
|
|
144
|
+
self._fit_y_btn = QtWidgets.QToolButton()
|
|
145
|
+
self._fit_y_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['fit_y'][self._current_theme]))
|
|
146
|
+
self._fit_y_btn.setIconSize(QtCore.QSize(24, 24))
|
|
147
|
+
self._fit_y_btn.setToolTip('Fit Y data to axis (keep X range)')
|
|
148
|
+
self._fit_y_btn.clicked.connect(self._fit_y_autoscale)
|
|
149
|
+
|
|
150
|
+
# Measure lines toggle button
|
|
151
|
+
self.measure_btn = QtWidgets.QToolButton()
|
|
152
|
+
self.measure_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['measure'][self._current_theme]))
|
|
153
|
+
self.measure_btn.setIconSize(QtCore.QSize(24, 24))
|
|
154
|
+
self.measure_btn.setToolTip('Toggle measure cursors')
|
|
155
|
+
self.measure_btn.setCheckable(True)
|
|
156
|
+
self.measure_btn.toggled.connect(self._toggle_measure)
|
|
157
|
+
|
|
158
|
+
# X-axis selector button
|
|
159
|
+
self._x_axis_btn = QtWidgets.QToolButton()
|
|
160
|
+
self._x_axis_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['x_axis'][self._current_theme]))
|
|
161
|
+
self._x_axis_btn.setIconSize(QtCore.QSize(24, 24))
|
|
162
|
+
self._x_axis_btn.setToolTip('Select X axis series')
|
|
163
|
+
self._x_axis_btn.clicked.connect(self._show_x_axis_dialog)
|
|
164
|
+
|
|
165
|
+
self.nav_toolbar.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
|
|
166
|
+
self.nav_toolbar.setStyleSheet("QToolBar { background: transparent; border: none; }")
|
|
167
|
+
|
|
168
|
+
toolbar_layout = QtWidgets.QHBoxLayout()
|
|
169
|
+
toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
|
170
|
+
toolbar_layout.addWidget(self.nav_toolbar)
|
|
171
|
+
toolbar_layout.addWidget(self._add_subplot_btn)
|
|
172
|
+
toolbar_layout.addWidget(self._fit_y_btn)
|
|
173
|
+
toolbar_layout.addWidget(self.measure_btn)
|
|
174
|
+
toolbar_layout.addWidget(self._x_axis_btn)
|
|
175
|
+
toolbar_layout.addStretch(1)
|
|
176
|
+
|
|
177
|
+
plot_vlayout = QtWidgets.QVBoxLayout(plot_widget)
|
|
178
|
+
plot_vlayout.setContentsMargins(0, 0, 0, 0)
|
|
179
|
+
plot_vlayout.addLayout(toolbar_layout)
|
|
180
|
+
plot_vlayout.addWidget(self.canvas)
|
|
181
|
+
layout.addWidget(plot_widget, stretch=3)
|
|
182
|
+
|
|
183
|
+
# Status bar for mouse coordinates
|
|
184
|
+
self._coord_label = QtWidgets.QLabel('')
|
|
185
|
+
self.statusBar().addWidget(self._coord_label)
|
|
186
|
+
self.canvas.mpl_connect('motion_notify_event', self._on_mouse_move)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# default frequency used for series that depend on f
|
|
190
|
+
self.default_freq = 1.0
|
|
191
|
+
|
|
192
|
+
# Default style used when creating/updating subplot configuration
|
|
193
|
+
self.default_plot_style = {
|
|
194
|
+
'legend_show': True,
|
|
195
|
+
'legend_pos': 'best',
|
|
196
|
+
'legend_orient': 'vertical',
|
|
197
|
+
'legend_fontsize': 12,
|
|
198
|
+
'plot_mode': 'step',
|
|
199
|
+
'grid_show': True,
|
|
200
|
+
'marker': '',
|
|
201
|
+
}
|
|
202
|
+
self.available_plot_presets = {
|
|
203
|
+
'Default': styles.set_default_style,
|
|
204
|
+
'Style 1': styles.set_style1_style,
|
|
205
|
+
'Style 2': styles.set_style2_style,
|
|
206
|
+
'IEEE': styles.set_ieee_style,
|
|
207
|
+
}
|
|
208
|
+
self.selected_plot_preset = 'Default'
|
|
209
|
+
self.available_plot_presets[self.selected_plot_preset]()
|
|
210
|
+
|
|
211
|
+
# Prepare series definitions (5 series). x is shared.
|
|
212
|
+
self._x = np.linspace(0, 2 * np.pi, 400)
|
|
213
|
+
self._all_columns = {} # all loaded columns (name -> numpy array)
|
|
214
|
+
self._x_col_name = '' # current x-axis column name
|
|
215
|
+
self._skip_xlabel_capture = False # flag to skip xlabel capture during x-axis change
|
|
216
|
+
self._merged_mode = False # True when multiple files are merged
|
|
217
|
+
# Tracks loaded files: list of {'name': str, 'series_indices': [int, ...]}
|
|
218
|
+
self._loaded_files = []
|
|
219
|
+
# Each series is a callable that accepts x and freq and returns y
|
|
220
|
+
self.series_list = []
|
|
221
|
+
# Keep track of series per subplot: a list of lists. Each sublist contains indices into series_list.
|
|
222
|
+
# Start with no subplots (user requested the app start with none)
|
|
223
|
+
self.subplot_series = []
|
|
224
|
+
# Parallel structure to hold per-curve properties for each subplot.
|
|
225
|
+
# Each entry is a list of dicts matching the series in `subplot_series`.
|
|
226
|
+
self.subplot_series_props = []
|
|
227
|
+
# Per-subplot axes labels: a list of dicts with keys 'xlabel','ylabel_primary','ylabel_secondary'
|
|
228
|
+
self.subplot_axes_labels = []
|
|
229
|
+
# Per-subplot display config: legend and plot mode
|
|
230
|
+
# Each entry is a dict: {'legend_show': bool, 'legend_pos': str, 'legend_orient': str, 'plot_mode': str}
|
|
231
|
+
self.subplot_config = []
|
|
232
|
+
|
|
233
|
+
# Measure cursors state
|
|
234
|
+
self._measure_active = False
|
|
235
|
+
self._measure_lines = [] # list of axvline artists per axes
|
|
236
|
+
self._measure_x = [None, None] # x positions of the two cursors
|
|
237
|
+
self._measure_dragging = None # index (0 or 1) of cursor being dragged
|
|
238
|
+
self._measure_cids = [] # matplotlib event connection ids
|
|
239
|
+
self._measure_dialog = None
|
|
240
|
+
self._plotted_line_artists = [] # stored line artists per subplot for property capture
|
|
241
|
+
self._plotted_series_snapshot = [] # snapshot of subplot_series at last draw (for structure matching)
|
|
242
|
+
|
|
243
|
+
# Connect matplotlib double-clicks on the canvas to allow adding series to a clicked subplot
|
|
244
|
+
# Use mpl_connect to listen for mouse button press events; event.dblclick indicates a double-click.
|
|
245
|
+
self.canvas.mpl_connect('button_press_event', self.on_canvas_click)
|
|
246
|
+
|
|
247
|
+
# initial plot
|
|
248
|
+
self.update_plot()
|
|
249
|
+
self._apply_theme(self._current_theme)
|
|
250
|
+
|
|
251
|
+
def update_plot(self):
|
|
252
|
+
# Recreate subplots based on self.subplot_series
|
|
253
|
+
f = float(self.default_freq)
|
|
254
|
+
nsubs = len(self.subplot_series)
|
|
255
|
+
|
|
256
|
+
# Save current axis limits and labels before clearing
|
|
257
|
+
saved_limits = []
|
|
258
|
+
for i, ax in enumerate(getattr(self, 'current_axes', [])):
|
|
259
|
+
saved_limits.append((ax.get_xlim(), ax.get_ylim()))
|
|
260
|
+
# Capture labels that may have been edited via the navigation toolbar
|
|
261
|
+
if i < len(self.subplot_axes_labels):
|
|
262
|
+
current_xlabel = ax.get_xlabel()
|
|
263
|
+
current_ylabel = ax.get_ylabel()
|
|
264
|
+
# Only save xlabel if it's a user-set custom label (not the auto-generated x column name)
|
|
265
|
+
if current_xlabel and current_xlabel != self._x_col_name and not self._skip_xlabel_capture:
|
|
266
|
+
self.subplot_axes_labels[i]['xlabel'] = current_xlabel
|
|
267
|
+
if current_ylabel:
|
|
268
|
+
self.subplot_axes_labels[i]['ylabel_primary'] = current_ylabel
|
|
269
|
+
# Capture series properties (label, color, linewidth, linestyle, marker) from stored line artists
|
|
270
|
+
# Only capture if this subplot's structure hasn't changed since last draw
|
|
271
|
+
if (i < len(self._plotted_line_artists)
|
|
272
|
+
and i < len(self._plotted_series_snapshot)
|
|
273
|
+
and i < len(self.subplot_series)
|
|
274
|
+
and self._plotted_series_snapshot[i] == self.subplot_series[i]):
|
|
275
|
+
line_artists = self._plotted_line_artists[i]
|
|
276
|
+
if i < len(self.subplot_series_props):
|
|
277
|
+
for j, line in enumerate(line_artists):
|
|
278
|
+
if j < len(self.subplot_series_props[i]):
|
|
279
|
+
label = line.get_label()
|
|
280
|
+
if label and not label.startswith('_'):
|
|
281
|
+
self.subplot_series_props[i][j]['label'] = label
|
|
282
|
+
color = line.get_color()
|
|
283
|
+
if color:
|
|
284
|
+
self.subplot_series_props[i][j]['color'] = color
|
|
285
|
+
self.subplot_series_props[i][j]['linewidth'] = line.get_linewidth()
|
|
286
|
+
self.subplot_series_props[i][j]['linestyle'] = line.get_linestyle()
|
|
287
|
+
marker = line.get_marker()
|
|
288
|
+
if marker and marker != 'None':
|
|
289
|
+
self.subplot_series_props[i][j]['marker'] = marker
|
|
290
|
+
else:
|
|
291
|
+
self.subplot_series_props[i][j]['marker'] = ''
|
|
292
|
+
|
|
293
|
+
# Clear and create new axes array with shared x
|
|
294
|
+
self.fig.clear()
|
|
295
|
+
self._plotted_line_artists = []
|
|
296
|
+
self._plotted_series_snapshot = [list(s) for s in self.subplot_series]
|
|
297
|
+
if nsubs == 0:
|
|
298
|
+
# no subplots: clear figure and draw blank canvas
|
|
299
|
+
self.current_axes = []
|
|
300
|
+
self.canvas.draw()
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if nsubs == 1:
|
|
304
|
+
axes = [self.fig.add_subplot(111)]
|
|
305
|
+
else:
|
|
306
|
+
axes = self.fig.subplots(nsubs, 1, sharex=True)
|
|
307
|
+
# ensure axes is a list
|
|
308
|
+
if not isinstance(axes, (list, np.ndarray)):
|
|
309
|
+
axes = [axes]
|
|
310
|
+
else:
|
|
311
|
+
axes = list(axes)
|
|
312
|
+
|
|
313
|
+
# Keep a reference to current axes so we can map mouse events to subplot indices
|
|
314
|
+
self.current_axes = axes
|
|
315
|
+
|
|
316
|
+
# Plot each subplot's series
|
|
317
|
+
for idx, series_indices in enumerate(self.subplot_series):
|
|
318
|
+
ax = axes[idx]
|
|
319
|
+
# retrieve axis labels for this subplot if any
|
|
320
|
+
ax_labels = {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
|
|
321
|
+
if idx < len(self.subplot_axes_labels):
|
|
322
|
+
ax_labels.update(self.subplot_axes_labels[idx])
|
|
323
|
+
props_list = []
|
|
324
|
+
if idx < len(self.subplot_series_props):
|
|
325
|
+
props_list = self.subplot_series_props[idx]
|
|
326
|
+
# Get subplot config
|
|
327
|
+
cfg = dict(self.default_plot_style)
|
|
328
|
+
if idx < len(self.subplot_config):
|
|
329
|
+
cfg.update(self.subplot_config[idx])
|
|
330
|
+
plot_fn_name = cfg.get('plot_mode', 'step')
|
|
331
|
+
axis_text_size = cfg.get('legend_fontsize', self.default_plot_style['legend_fontsize'])
|
|
332
|
+
|
|
333
|
+
# Plot each series assigned to this subplot
|
|
334
|
+
subplot_line_artists = []
|
|
335
|
+
for j, series_idx in enumerate(series_indices):
|
|
336
|
+
name, func, x_data = self.series_list[series_idx]
|
|
337
|
+
y = func(x_data, f)
|
|
338
|
+
# get curve properties if present
|
|
339
|
+
if j < len(props_list):
|
|
340
|
+
p = props_list[j]
|
|
341
|
+
color = p.get('color', '')
|
|
342
|
+
lw = p.get('linewidth', 1.5)
|
|
343
|
+
ls = p.get('linestyle', '')
|
|
344
|
+
marker = p.get('marker', '')
|
|
345
|
+
label = p.get('label', name)
|
|
346
|
+
else:
|
|
347
|
+
color = ''
|
|
348
|
+
lw = 1.5
|
|
349
|
+
ls = ''
|
|
350
|
+
marker = ''
|
|
351
|
+
label = name
|
|
352
|
+
# choose axis
|
|
353
|
+
yaxis = 1
|
|
354
|
+
if j < len(props_list):
|
|
355
|
+
yaxis = props_list[j].get('yaxis', 1)
|
|
356
|
+
|
|
357
|
+
if not marker:
|
|
358
|
+
marker = cfg.get('marker', '')
|
|
359
|
+
label = label.strip('_') # remove leading/trailing underscores for better math text parsing
|
|
360
|
+
draw_kwargs = dict(linewidth=lw, label=label)
|
|
361
|
+
if ls:
|
|
362
|
+
draw_kwargs['linestyle'] = ls
|
|
363
|
+
if marker:
|
|
364
|
+
draw_kwargs['marker'] = marker
|
|
365
|
+
if color:
|
|
366
|
+
draw_kwargs['color'] = color
|
|
367
|
+
target_ax = ax
|
|
368
|
+
if yaxis != 1:
|
|
369
|
+
target_ax = ax.twinx()
|
|
370
|
+
if plot_fn_name == 'step':
|
|
371
|
+
step_result = target_ax.step(x_data, y, where='post', **draw_kwargs)
|
|
372
|
+
subplot_line_artists.append(step_result[0])
|
|
373
|
+
else:
|
|
374
|
+
plot_result = target_ax.plot(x_data, y, **draw_kwargs)
|
|
375
|
+
subplot_line_artists.append(plot_result[0])
|
|
376
|
+
if yaxis != 1:
|
|
377
|
+
ax_ylabel_secondary = ax_labels.get('ylabel_secondary')
|
|
378
|
+
if ax_ylabel_secondary:
|
|
379
|
+
target_ax.set_ylabel(ax_ylabel_secondary, fontsize=axis_text_size)
|
|
380
|
+
target_ax.tick_params(axis='both', labelsize=axis_text_size)
|
|
381
|
+
self._plotted_line_artists.append(subplot_line_artists)
|
|
382
|
+
# Configure axis
|
|
383
|
+
ax_xlabel = ax_labels.get('xlabel')
|
|
384
|
+
ax_ylabel = ax_labels.get('ylabel_primary')
|
|
385
|
+
# X label only on the last subplot
|
|
386
|
+
if idx == nsubs - 1:
|
|
387
|
+
# Check if any subplot has a custom xlabel and use it
|
|
388
|
+
custom_xlabel = ''
|
|
389
|
+
for lbl in self.subplot_axes_labels:
|
|
390
|
+
if lbl.get('xlabel'):
|
|
391
|
+
custom_xlabel = lbl['xlabel']
|
|
392
|
+
if custom_xlabel:
|
|
393
|
+
ax.set_xlabel(custom_xlabel, fontsize=axis_text_size)
|
|
394
|
+
elif self._x_col_name:
|
|
395
|
+
ax.set_xlabel(self._x_col_name, fontsize=axis_text_size)
|
|
396
|
+
if ax_ylabel:
|
|
397
|
+
ax.set_ylabel(ax_ylabel, fontsize=axis_text_size)
|
|
398
|
+
ax.tick_params(axis='both', labelsize=axis_text_size)
|
|
399
|
+
if cfg.get('grid_show', True):
|
|
400
|
+
ax.grid(True, color='0.95')
|
|
401
|
+
# Legend
|
|
402
|
+
if cfg.get('legend_show', True) and len(series_indices) > 0:
|
|
403
|
+
ncol = len(series_indices) if cfg.get('legend_orient') == 'horizontal' else 1
|
|
404
|
+
legend_fs = cfg.get('legend_fontsize', self.default_plot_style['legend_fontsize'])
|
|
405
|
+
legend_pos = cfg.get('legend_pos', 'best')
|
|
406
|
+
outside_map = {
|
|
407
|
+
'outside right': ('upper left', (1.02, 1)),
|
|
408
|
+
'outside left': ('upper right', (-0.02, 1)),
|
|
409
|
+
'outside top': ('lower left', (0, 1.02)),
|
|
410
|
+
'outside upper right': ('upper left', (1.02, 1)),
|
|
411
|
+
'outside lower right': ('lower left', (1.02, 0)),
|
|
412
|
+
}
|
|
413
|
+
if legend_pos in outside_map:
|
|
414
|
+
loc, bbox = outside_map[legend_pos]
|
|
415
|
+
ax.legend(loc=loc, bbox_to_anchor=bbox, borderaxespad=0, ncol=ncol, fontsize=legend_fs)
|
|
416
|
+
else:
|
|
417
|
+
ax.legend(loc=legend_pos, ncol=ncol, fontsize=legend_fs)
|
|
418
|
+
|
|
419
|
+
# Restore saved axis limits if available for this subplot
|
|
420
|
+
if idx < len(saved_limits):
|
|
421
|
+
ax.set_xlim(saved_limits[idx][0])
|
|
422
|
+
ax.set_ylim(saved_limits[idx][1])
|
|
423
|
+
|
|
424
|
+
self.fig.tight_layout()
|
|
425
|
+
# Redraw measure lines if active
|
|
426
|
+
if self._measure_active:
|
|
427
|
+
self._draw_measure_lines()
|
|
428
|
+
self._update_measure_dialog()
|
|
429
|
+
self.canvas.draw()
|
|
430
|
+
|
|
431
|
+
def on_randomize(self):
|
|
432
|
+
# Randomize button removed; keep method no-op in case called elsewhere
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
def _on_mouse_move(self, event):
|
|
436
|
+
"""Update status bar with mouse coordinates when hovering over a subplot."""
|
|
437
|
+
if event.inaxes is not None:
|
|
438
|
+
self._coord_label.setText(f'x = {event.xdata:.6g} y = {event.ydata:.6g}')
|
|
439
|
+
else:
|
|
440
|
+
self._coord_label.setText('')
|
|
441
|
+
|
|
442
|
+
def _home_autoscale(self, *args):
|
|
443
|
+
"""Reset all subplot axes to fit all plotted data."""
|
|
444
|
+
for ax in getattr(self, 'current_axes', []):
|
|
445
|
+
ax.relim()
|
|
446
|
+
ax.autoscale(enable=True, axis='y', tight=False)
|
|
447
|
+
ax.autoscale(enable=True, axis='x', tight=True)
|
|
448
|
+
self.canvas.draw_idle()
|
|
449
|
+
|
|
450
|
+
def _show_x_axis_dialog(self):
|
|
451
|
+
"""Open a dialog to select which column to use as the X axis."""
|
|
452
|
+
if not self._all_columns:
|
|
453
|
+
QtWidgets.QMessageBox.information(self, 'X Axis', 'No data loaded. Open a file first.')
|
|
454
|
+
return
|
|
455
|
+
if self._merged_mode:
|
|
456
|
+
QtWidgets.QMessageBox.information(self, 'X Axis', 'X axis selection is not available in merge mode. Each file uses its own X axis.')
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
dlg = QtWidgets.QDialog(self)
|
|
460
|
+
dlg.setWindowTitle('Select X Axis')
|
|
461
|
+
dlg.setMinimumWidth(300)
|
|
462
|
+
layout = QtWidgets.QFormLayout(dlg)
|
|
463
|
+
|
|
464
|
+
combo = QtWidgets.QComboBox()
|
|
465
|
+
combo.addItems(list(self._all_columns.keys()))
|
|
466
|
+
combo.setCurrentText(self._x_col_name)
|
|
467
|
+
layout.addRow('X axis column:', combo)
|
|
468
|
+
|
|
469
|
+
buttons = QtWidgets.QDialogButtonBox(
|
|
470
|
+
QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel
|
|
471
|
+
)
|
|
472
|
+
layout.addRow(buttons)
|
|
473
|
+
|
|
474
|
+
def on_ok():
|
|
475
|
+
selected = combo.currentText()
|
|
476
|
+
if selected and selected != self._x_col_name:
|
|
477
|
+
self._change_x_axis(selected)
|
|
478
|
+
dlg.accept()
|
|
479
|
+
|
|
480
|
+
buttons.accepted.connect(on_ok)
|
|
481
|
+
buttons.rejected.connect(dlg.reject)
|
|
482
|
+
dlg.exec()
|
|
483
|
+
|
|
484
|
+
def _change_x_axis(self, col_name):
|
|
485
|
+
"""Switch the x-axis to a different loaded column."""
|
|
486
|
+
if not col_name or col_name not in self._all_columns:
|
|
487
|
+
return
|
|
488
|
+
new_x = self._all_columns[col_name]
|
|
489
|
+
|
|
490
|
+
# Build a map from old series index -> series name
|
|
491
|
+
old_names = {i: name for i, (name, *_rest) in enumerate(self.series_list)}
|
|
492
|
+
|
|
493
|
+
self._x = new_x
|
|
494
|
+
self._x_col_name = col_name
|
|
495
|
+
|
|
496
|
+
# Rebuild series_list from all_columns, excluding the new x column
|
|
497
|
+
self.series_list.clear()
|
|
498
|
+
new_indices = []
|
|
499
|
+
for name, data in self._all_columns.items():
|
|
500
|
+
if name == col_name:
|
|
501
|
+
continue
|
|
502
|
+
idx = len(self.series_list)
|
|
503
|
+
self.series_list.append((name, lambda x, f, _d=data: _d, new_x))
|
|
504
|
+
new_indices.append(idx)
|
|
505
|
+
|
|
506
|
+
# Update loaded file tracking
|
|
507
|
+
if self._loaded_files:
|
|
508
|
+
self._loaded_files[0]['series_indices'] = new_indices
|
|
509
|
+
|
|
510
|
+
# Build a map from series name -> new index
|
|
511
|
+
new_index_by_name = {name: i for i, (name, *_rest) in enumerate(self.series_list)}
|
|
512
|
+
|
|
513
|
+
# Remap existing subplot assignments to the new indices
|
|
514
|
+
new_subplot_series = []
|
|
515
|
+
new_subplot_props = []
|
|
516
|
+
new_subplot_labels = []
|
|
517
|
+
new_subplot_config = []
|
|
518
|
+
for sub_idx, old_indices in enumerate(self.subplot_series):
|
|
519
|
+
remapped_indices = []
|
|
520
|
+
remapped_props = []
|
|
521
|
+
props = self.subplot_series_props[sub_idx] if sub_idx < len(self.subplot_series_props) else []
|
|
522
|
+
for j, old_idx in enumerate(old_indices):
|
|
523
|
+
name = old_names.get(old_idx)
|
|
524
|
+
if name and name in new_index_by_name:
|
|
525
|
+
remapped_indices.append(new_index_by_name[name])
|
|
526
|
+
if j < len(props):
|
|
527
|
+
remapped_props.append(props[j])
|
|
528
|
+
if remapped_indices:
|
|
529
|
+
new_subplot_series.append(remapped_indices)
|
|
530
|
+
new_subplot_props.append(remapped_props)
|
|
531
|
+
new_subplot_labels.append(
|
|
532
|
+
self.subplot_axes_labels[sub_idx] if sub_idx < len(self.subplot_axes_labels)
|
|
533
|
+
else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
|
|
534
|
+
)
|
|
535
|
+
new_subplot_config.append(
|
|
536
|
+
self.subplot_config[sub_idx] if sub_idx < len(self.subplot_config)
|
|
537
|
+
else self._make_default_subplot_config()
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
self.subplot_series = new_subplot_series
|
|
541
|
+
self.subplot_series_props = new_subplot_props
|
|
542
|
+
self.subplot_axes_labels = new_subplot_labels
|
|
543
|
+
self.subplot_config = new_subplot_config
|
|
544
|
+
|
|
545
|
+
# Reset x labels on all subplots so the new x column name is used
|
|
546
|
+
for lbl in self.subplot_axes_labels:
|
|
547
|
+
lbl['xlabel'] = ''
|
|
548
|
+
|
|
549
|
+
self._skip_xlabel_capture = True
|
|
550
|
+
self.update_plot()
|
|
551
|
+
self._skip_xlabel_capture = False
|
|
552
|
+
|
|
553
|
+
def _fit_y_autoscale(self, *args):
|
|
554
|
+
"""Fit Y data to axis while preserving the current X range."""
|
|
555
|
+
for ax in getattr(self, 'current_axes', []):
|
|
556
|
+
xlim = ax.get_xlim()
|
|
557
|
+
ax.relim()
|
|
558
|
+
ax.autoscale(enable=True, axis='y', tight=False)
|
|
559
|
+
ax.set_xlim(xlim)
|
|
560
|
+
self.canvas.draw_idle()
|
|
561
|
+
|
|
562
|
+
def _apply_theme(self, theme):
|
|
563
|
+
"""Switch between 'dark' and 'light' themes."""
|
|
564
|
+
self._current_theme = theme
|
|
565
|
+
qdarktheme.setup_theme(theme)
|
|
566
|
+
|
|
567
|
+
# Update navigation toolbar icons
|
|
568
|
+
for action in self.nav_toolbar.actions():
|
|
569
|
+
if action.text() in self._icons_buttons:
|
|
570
|
+
icon_path = self._icons_buttons[action.text()][theme]
|
|
571
|
+
if os.path.exists(icon_path):
|
|
572
|
+
action.setIcon(QtGui.QIcon(icon_path))
|
|
573
|
+
|
|
574
|
+
# Update extra toolbar button icons
|
|
575
|
+
self._add_subplot_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['add_subplot'][theme]))
|
|
576
|
+
self._fit_y_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['fit_y'][theme]))
|
|
577
|
+
self.measure_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['measure'][theme]))
|
|
578
|
+
self._x_axis_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['x_axis'][theme]))
|
|
579
|
+
|
|
580
|
+
# Update theme menu checkmarks
|
|
581
|
+
for t, a in self._theme_actions.items():
|
|
582
|
+
a.setChecked(t == theme)
|
|
583
|
+
|
|
584
|
+
def _make_default_subplot_config(self):
|
|
585
|
+
return dict(self.default_plot_style)
|
|
586
|
+
|
|
587
|
+
def show_plot_style_dialog(self):
|
|
588
|
+
dlg = QtWidgets.QDialog(self)
|
|
589
|
+
dlg.setWindowTitle('Customize Plot Style')
|
|
590
|
+
layout = QtWidgets.QFormLayout(dlg)
|
|
591
|
+
|
|
592
|
+
preset_combo = QtWidgets.QComboBox()
|
|
593
|
+
preset_combo.addItems(list(self.available_plot_presets.keys()))
|
|
594
|
+
preset_combo.setCurrentText(self.selected_plot_preset)
|
|
595
|
+
|
|
596
|
+
plot_mode_combo = QtWidgets.QComboBox()
|
|
597
|
+
plot_mode_combo.addItems(['step', 'plot'])
|
|
598
|
+
plot_mode_combo.setCurrentText(self.default_plot_style.get('plot_mode', 'step'))
|
|
599
|
+
|
|
600
|
+
grid_show_check = QtWidgets.QCheckBox('Show grid')
|
|
601
|
+
grid_show_check.setChecked(bool(self.default_plot_style.get('grid_show', True)))
|
|
602
|
+
|
|
603
|
+
marker_combo = QtWidgets.QComboBox()
|
|
604
|
+
marker_combo.addItems(['', 'o', 's', '^', 'v', 'x', '+', '*', '.', 'D', '|', '_'])
|
|
605
|
+
marker_combo.setCurrentText(self.default_plot_style.get('marker', ''))
|
|
606
|
+
|
|
607
|
+
legend_show_check = QtWidgets.QCheckBox('Show legend by default')
|
|
608
|
+
legend_show_check.setChecked(bool(self.default_plot_style.get('legend_show', True)))
|
|
609
|
+
|
|
610
|
+
legend_pos_combo = QtWidgets.QComboBox()
|
|
611
|
+
legend_pos_combo.addItems(LEGEND_POSITIONS)
|
|
612
|
+
legend_pos_combo.setCurrentText(self.default_plot_style.get('legend_pos', 'best'))
|
|
613
|
+
|
|
614
|
+
legend_orient_combo = QtWidgets.QComboBox()
|
|
615
|
+
legend_orient_combo.addItems(['vertical', 'horizontal'])
|
|
616
|
+
legend_orient_combo.setCurrentText(self.default_plot_style.get('legend_orient', 'vertical'))
|
|
617
|
+
|
|
618
|
+
text_size_spin = QtWidgets.QSpinBox()
|
|
619
|
+
text_size_spin.setRange(6, 24)
|
|
620
|
+
text_size_spin.setValue(int(self.default_plot_style.get('legend_fontsize', 12)))
|
|
621
|
+
|
|
622
|
+
apply_existing_check = QtWidgets.QCheckBox('Apply to existing subplots now')
|
|
623
|
+
apply_existing_check.setChecked(True)
|
|
624
|
+
|
|
625
|
+
layout.addRow('Preset from styles.py', preset_combo)
|
|
626
|
+
layout.addRow('Plot style', plot_mode_combo)
|
|
627
|
+
layout.addRow('Marker', marker_combo)
|
|
628
|
+
layout.addRow('Text size', text_size_spin)
|
|
629
|
+
layout.addRow('', grid_show_check)
|
|
630
|
+
layout.addRow('', legend_show_check)
|
|
631
|
+
layout.addRow('Legend position', legend_pos_combo)
|
|
632
|
+
layout.addRow('Legend orientation', legend_orient_combo)
|
|
633
|
+
layout.addRow('', apply_existing_check)
|
|
634
|
+
|
|
635
|
+
buttons = QtWidgets.QDialogButtonBox(
|
|
636
|
+
QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel | QtWidgets.QDialogButtonBox.StandardButton.Apply
|
|
637
|
+
)
|
|
638
|
+
layout.addRow(buttons)
|
|
639
|
+
|
|
640
|
+
def apply_style():
|
|
641
|
+
selected_preset = preset_combo.currentText()
|
|
642
|
+
preset_fn = self.available_plot_presets.get(selected_preset)
|
|
643
|
+
if preset_fn is not None:
|
|
644
|
+
try:
|
|
645
|
+
preset_fn()
|
|
646
|
+
self.selected_plot_preset = selected_preset
|
|
647
|
+
except Exception as e:
|
|
648
|
+
QtWidgets.QMessageBox.warning(self, 'Style error', f'Failed to apply preset {selected_preset}:\n{e}')
|
|
649
|
+
|
|
650
|
+
if grid_show_check.isChecked() is True:
|
|
651
|
+
mpl.rcParams['axes.grid'] = True
|
|
652
|
+
mpl.rcParams['grid.alpha'] = 1.0
|
|
653
|
+
mpl.rcParams['grid.linewidth'] = 0.8
|
|
654
|
+
mpl.rcParams['lines.markersize'] = mpl.rcParams['lines.linewidth'] * 2.0
|
|
655
|
+
|
|
656
|
+
new_defaults = {
|
|
657
|
+
'legend_show': legend_show_check.isChecked(),
|
|
658
|
+
'legend_pos': legend_pos_combo.currentText(),
|
|
659
|
+
'legend_orient': legend_orient_combo.currentText(),
|
|
660
|
+
'legend_fontsize': int(text_size_spin.value()),
|
|
661
|
+
'plot_mode': plot_mode_combo.currentText(),
|
|
662
|
+
'grid_show': grid_show_check.isChecked(),
|
|
663
|
+
'marker': marker_combo.currentText(),
|
|
664
|
+
}
|
|
665
|
+
self.default_plot_style = dict(new_defaults)
|
|
666
|
+
|
|
667
|
+
if apply_existing_check.isChecked():
|
|
668
|
+
for i in range(len(self.subplot_config)):
|
|
669
|
+
self.subplot_config[i] = dict(new_defaults)
|
|
670
|
+
|
|
671
|
+
self.update_plot()
|
|
672
|
+
|
|
673
|
+
def on_ok():
|
|
674
|
+
apply_style()
|
|
675
|
+
dlg.accept()
|
|
676
|
+
|
|
677
|
+
buttons.accepted.connect(on_ok)
|
|
678
|
+
buttons.rejected.connect(dlg.reject)
|
|
679
|
+
buttons.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).clicked.connect(apply_style)
|
|
680
|
+
dlg.exec()
|
|
681
|
+
|
|
682
|
+
def open_files(self, paths):
|
|
683
|
+
"""Load a list of file paths. The first data file is opened normally; any additional files are merged."""
|
|
684
|
+
if not paths:
|
|
685
|
+
return
|
|
686
|
+
blf_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.blf', '.trc', '.asc')]
|
|
687
|
+
dbc_files = [p for p in paths if os.path.splitext(p)[1].lower() == '.dbc']
|
|
688
|
+
csv_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.csv', '.smv')]
|
|
689
|
+
mf4_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.mf4', '.mf4z')]
|
|
690
|
+
|
|
691
|
+
first_loaded = False
|
|
692
|
+
|
|
693
|
+
if blf_files and dbc_files:
|
|
694
|
+
for blf_path in blf_files:
|
|
695
|
+
if not first_loaded:
|
|
696
|
+
self._load_blf(blf_path, dbc_files)
|
|
697
|
+
first_loaded = True
|
|
698
|
+
else:
|
|
699
|
+
self._merged_mode = True
|
|
700
|
+
self._merge_load_blf(blf_path, dbc_paths=dbc_files, prefix=os.path.basename(blf_path))
|
|
701
|
+
|
|
702
|
+
for path in csv_files:
|
|
703
|
+
if not first_loaded:
|
|
704
|
+
self._load_csv(path)
|
|
705
|
+
first_loaded = True
|
|
706
|
+
else:
|
|
707
|
+
self._merged_mode = True
|
|
708
|
+
self._merge_load_csv(path, os.path.basename(path))
|
|
709
|
+
|
|
710
|
+
for path in mf4_files:
|
|
711
|
+
if not first_loaded:
|
|
712
|
+
self._load_mf4(path)
|
|
713
|
+
first_loaded = True
|
|
714
|
+
else:
|
|
715
|
+
self._merged_mode = True
|
|
716
|
+
self._merge_load_mf4(path, os.path.basename(path))
|
|
717
|
+
|
|
718
|
+
if self._merged_mode and self.series_list:
|
|
719
|
+
file_names = [f['name'] for f in self._loaded_files]
|
|
720
|
+
self.update_plot()
|
|
721
|
+
self.setWindowTitle(f'Toucan-Plot — Merged: {", ".join(file_names)}')
|
|
722
|
+
|
|
723
|
+
def open_file(self):
|
|
724
|
+
file_filter = "All supported files (*.csv *.smv *.blf *.trc *.asc *.dbc *.mf4 *.mf4z);;CSV files (*.csv);;SMV files (*.smv);;MF4 files (*.mf4 *.mf4z);;CAN files (*.blf *.trc *.asc *.dbc);;All files (*)"
|
|
725
|
+
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(self, 'Open File', '', file_filter)
|
|
726
|
+
self.open_files(paths)
|
|
727
|
+
|
|
728
|
+
def merge_file(self):
|
|
729
|
+
"""Open additional files and append their series to the existing plot session."""
|
|
730
|
+
file_filter = "All supported files (*.csv *.smv *.blf *.trc *.asc *.dbc *.mf4 *.mf4z);;CSV files (*.csv);;SMV files (*.smv);;MF4 files (*.mf4 *.mf4z);;CAN files (*.blf *.trc *.asc *.dbc);;All files (*)"
|
|
731
|
+
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(self, 'Merge Files', '', file_filter)
|
|
732
|
+
if not paths:
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
self._merged_mode = True
|
|
736
|
+
|
|
737
|
+
blf_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.blf', '.trc', '.asc')]
|
|
738
|
+
dbc_files = [p for p in paths if os.path.splitext(p)[1].lower() == '.dbc']
|
|
739
|
+
csv_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.csv', '.smv')]
|
|
740
|
+
mf4_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.mf4', '.mf4z')]
|
|
741
|
+
|
|
742
|
+
for path in csv_files:
|
|
743
|
+
basename = os.path.basename(path)
|
|
744
|
+
self._merge_load_csv(path, basename)
|
|
745
|
+
|
|
746
|
+
if blf_files and dbc_files:
|
|
747
|
+
for blf_path in blf_files:
|
|
748
|
+
basename = os.path.basename(blf_path)
|
|
749
|
+
self._merge_load_blf(blf_path, dbc_paths=dbc_files, prefix=basename)
|
|
750
|
+
|
|
751
|
+
for path in mf4_files:
|
|
752
|
+
basename = os.path.basename(path)
|
|
753
|
+
self._merge_load_mf4(path, basename)
|
|
754
|
+
|
|
755
|
+
if self.series_list:
|
|
756
|
+
file_names = [f['name'] for f in self._loaded_files]
|
|
757
|
+
self.update_plot()
|
|
758
|
+
self.setWindowTitle(f'Toucan-Plot — Merged: {", ".join(file_names)}')
|
|
759
|
+
self.show_series_selector()
|
|
760
|
+
|
|
761
|
+
def _merge_load_csv(self, path, prefix):
|
|
762
|
+
"""Load a CSV/SMV file and append its series (prefixed) to the existing series_list."""
|
|
763
|
+
queue = multiprocessing.Queue()
|
|
764
|
+
proc = multiprocessing.Process(target=csv_load_worker, args=(path, queue))
|
|
765
|
+
|
|
766
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
|
|
767
|
+
progress.setWindowTitle('Loading')
|
|
768
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
769
|
+
progress.setMinimumDuration(0)
|
|
770
|
+
progress.setValue(0)
|
|
771
|
+
|
|
772
|
+
result_data = {}
|
|
773
|
+
|
|
774
|
+
def poll():
|
|
775
|
+
try:
|
|
776
|
+
while not queue.empty():
|
|
777
|
+
msg = queue.get_nowait()
|
|
778
|
+
if msg[0] == 'progress':
|
|
779
|
+
pct = msg[1]
|
|
780
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
781
|
+
if pct >= 0:
|
|
782
|
+
progress.setValue(pct)
|
|
783
|
+
if label:
|
|
784
|
+
progress.setLabelText(label)
|
|
785
|
+
elif msg[0] == 'result':
|
|
786
|
+
result_data['data'] = msg[1]
|
|
787
|
+
progress.setValue(100)
|
|
788
|
+
progress.close()
|
|
789
|
+
elif msg[0] == 'error':
|
|
790
|
+
result_data['error'] = msg[1]
|
|
791
|
+
progress.close()
|
|
792
|
+
except Exception:
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
timer = QtCore.QTimer(progress)
|
|
796
|
+
timer.timeout.connect(poll)
|
|
797
|
+
timer.start(50)
|
|
798
|
+
proc.start()
|
|
799
|
+
progress.exec()
|
|
800
|
+
timer.stop()
|
|
801
|
+
proc.join(timeout=10)
|
|
802
|
+
|
|
803
|
+
if 'error' in result_data:
|
|
804
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
805
|
+
return
|
|
806
|
+
if 'data' not in result_data:
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
x_col, all_columns = result_data['data']
|
|
810
|
+
x_array = all_columns[x_col]
|
|
811
|
+
|
|
812
|
+
# Use the first file's x as the default for measure cursor placement
|
|
813
|
+
if len(self.series_list) == 0:
|
|
814
|
+
self._x = x_array
|
|
815
|
+
self._x_col_name = x_col
|
|
816
|
+
|
|
817
|
+
# Append series with file prefix and track file
|
|
818
|
+
file_entry = {'name': prefix, 'series_indices': []}
|
|
819
|
+
for name in all_columns:
|
|
820
|
+
if name == x_col:
|
|
821
|
+
continue
|
|
822
|
+
data = all_columns[name]
|
|
823
|
+
prefixed_name = f'{prefix}: {name}'
|
|
824
|
+
idx = len(self.series_list)
|
|
825
|
+
self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
|
|
826
|
+
file_entry['series_indices'].append(idx)
|
|
827
|
+
self._loaded_files.append(file_entry)
|
|
828
|
+
|
|
829
|
+
def _merge_load_blf(self, blf_path, dbc_paths, prefix):
|
|
830
|
+
"""Load a BLF/TRC/ASC file and append its series (prefixed) to the existing series_list."""
|
|
831
|
+
queue = multiprocessing.Queue()
|
|
832
|
+
proc = multiprocessing.Process(target=can_log_load_worker, args=(blf_path, dbc_paths, queue))
|
|
833
|
+
|
|
834
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(blf_path)}...', None, 0, 100, self)
|
|
835
|
+
progress.setWindowTitle('Loading')
|
|
836
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
837
|
+
progress.setMinimumDuration(0)
|
|
838
|
+
progress.setValue(0)
|
|
839
|
+
|
|
840
|
+
result_data = {}
|
|
841
|
+
|
|
842
|
+
def poll():
|
|
843
|
+
try:
|
|
844
|
+
while not queue.empty():
|
|
845
|
+
msg = queue.get_nowait()
|
|
846
|
+
if msg[0] == 'progress':
|
|
847
|
+
pct = msg[1]
|
|
848
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
849
|
+
if pct >= 0:
|
|
850
|
+
progress.setValue(pct)
|
|
851
|
+
if label:
|
|
852
|
+
progress.setLabelText(label)
|
|
853
|
+
elif msg[0] == 'result':
|
|
854
|
+
result_data['data'] = msg[1]
|
|
855
|
+
progress.setValue(100)
|
|
856
|
+
progress.close()
|
|
857
|
+
elif msg[0] == 'error':
|
|
858
|
+
result_data['error'] = msg[1]
|
|
859
|
+
progress.close()
|
|
860
|
+
except Exception:
|
|
861
|
+
pass
|
|
862
|
+
|
|
863
|
+
timer = QtCore.QTimer(progress)
|
|
864
|
+
timer.timeout.connect(poll)
|
|
865
|
+
timer.start(50)
|
|
866
|
+
proc.start()
|
|
867
|
+
progress.exec()
|
|
868
|
+
timer.stop()
|
|
869
|
+
proc.join(timeout=10)
|
|
870
|
+
|
|
871
|
+
if 'error' in result_data:
|
|
872
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
873
|
+
return
|
|
874
|
+
if 'data' not in result_data:
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
x_col, all_columns = result_data['data']
|
|
878
|
+
x_array = all_columns[x_col]
|
|
879
|
+
|
|
880
|
+
# Use the first file's x as the default for measure cursor placement
|
|
881
|
+
if len(self.series_list) == 0:
|
|
882
|
+
self._x = x_array
|
|
883
|
+
self._x_col_name = x_col
|
|
884
|
+
|
|
885
|
+
# Append series with file prefix and track file
|
|
886
|
+
file_entry = {'name': prefix, 'series_indices': []}
|
|
887
|
+
for name in all_columns:
|
|
888
|
+
if name == x_col:
|
|
889
|
+
continue
|
|
890
|
+
data = all_columns[name]
|
|
891
|
+
prefixed_name = f'{prefix}: {name}'
|
|
892
|
+
idx = len(self.series_list)
|
|
893
|
+
self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
|
|
894
|
+
file_entry['series_indices'].append(idx)
|
|
895
|
+
self._loaded_files.append(file_entry)
|
|
896
|
+
|
|
897
|
+
def _load_csv(self, path):
|
|
898
|
+
"""Load a CSV/SMV file using a worker process with live progress."""
|
|
899
|
+
queue = multiprocessing.Queue()
|
|
900
|
+
proc = multiprocessing.Process(target=csv_load_worker, args=(path, queue))
|
|
901
|
+
|
|
902
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
|
|
903
|
+
progress.setWindowTitle('Loading')
|
|
904
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
905
|
+
progress.setMinimumDuration(0)
|
|
906
|
+
progress.setValue(0)
|
|
907
|
+
|
|
908
|
+
result_data = {}
|
|
909
|
+
|
|
910
|
+
def poll():
|
|
911
|
+
try:
|
|
912
|
+
while not queue.empty():
|
|
913
|
+
msg = queue.get_nowait()
|
|
914
|
+
if msg[0] == 'progress':
|
|
915
|
+
pct = msg[1]
|
|
916
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
917
|
+
if pct >= 0:
|
|
918
|
+
progress.setValue(pct)
|
|
919
|
+
if label:
|
|
920
|
+
progress.setLabelText(label)
|
|
921
|
+
elif msg[0] == 'result':
|
|
922
|
+
result_data['data'] = msg[1]
|
|
923
|
+
progress.setValue(100)
|
|
924
|
+
progress.close()
|
|
925
|
+
elif msg[0] == 'error':
|
|
926
|
+
result_data['error'] = msg[1]
|
|
927
|
+
progress.close()
|
|
928
|
+
except Exception:
|
|
929
|
+
pass
|
|
930
|
+
|
|
931
|
+
timer = QtCore.QTimer(progress)
|
|
932
|
+
timer.timeout.connect(poll)
|
|
933
|
+
timer.start(50)
|
|
934
|
+
proc.start()
|
|
935
|
+
progress.exec()
|
|
936
|
+
timer.stop()
|
|
937
|
+
proc.join(timeout=10)
|
|
938
|
+
|
|
939
|
+
if 'error' in result_data:
|
|
940
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
941
|
+
return
|
|
942
|
+
if 'data' not in result_data:
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
x_col, all_columns = result_data['data']
|
|
946
|
+
x_array = all_columns[x_col]
|
|
947
|
+
|
|
948
|
+
self._x = x_array
|
|
949
|
+
self._all_columns = all_columns
|
|
950
|
+
self._x_col_name = x_col
|
|
951
|
+
self._merged_mode = False
|
|
952
|
+
|
|
953
|
+
# Clear existing series and subplots
|
|
954
|
+
self.series_list.clear()
|
|
955
|
+
self.subplot_series.clear()
|
|
956
|
+
self.subplot_series_props.clear()
|
|
957
|
+
self.subplot_axes_labels.clear()
|
|
958
|
+
self.subplot_config.clear()
|
|
959
|
+
self._loaded_files.clear()
|
|
960
|
+
|
|
961
|
+
# Build series_list from all_columns (lambdas can't cross process boundary)
|
|
962
|
+
basename = os.path.basename(path)
|
|
963
|
+
file_entry = {'name': basename, 'series_indices': []}
|
|
964
|
+
for name in all_columns:
|
|
965
|
+
if name == x_col:
|
|
966
|
+
continue
|
|
967
|
+
data = all_columns[name]
|
|
968
|
+
idx = len(self.series_list)
|
|
969
|
+
self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
|
|
970
|
+
file_entry['series_indices'].append(idx)
|
|
971
|
+
self._loaded_files.append(file_entry)
|
|
972
|
+
self._merge_action.setEnabled(True)
|
|
973
|
+
|
|
974
|
+
self.update_plot()
|
|
975
|
+
self.setWindowTitle(f'Toucan-Plot — {basename}')
|
|
976
|
+
self.show_series_selector()
|
|
977
|
+
|
|
978
|
+
def _load_blf(self, blf_path, dbc_paths):
|
|
979
|
+
"""Load a BLF file using a worker process with live progress."""
|
|
980
|
+
queue = multiprocessing.Queue()
|
|
981
|
+
proc = multiprocessing.Process(target=can_log_load_worker, args=(blf_path, dbc_paths, queue))
|
|
982
|
+
|
|
983
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(blf_path)}...', None, 0, 100, self)
|
|
984
|
+
progress.setWindowTitle('Loading')
|
|
985
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
986
|
+
progress.setMinimumDuration(0)
|
|
987
|
+
progress.setValue(0)
|
|
988
|
+
|
|
989
|
+
result_data = {}
|
|
990
|
+
|
|
991
|
+
def poll():
|
|
992
|
+
try:
|
|
993
|
+
while not queue.empty():
|
|
994
|
+
msg = queue.get_nowait()
|
|
995
|
+
if msg[0] == 'progress':
|
|
996
|
+
pct = msg[1]
|
|
997
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
998
|
+
if pct >= 0:
|
|
999
|
+
progress.setValue(pct)
|
|
1000
|
+
if label:
|
|
1001
|
+
progress.setLabelText(label)
|
|
1002
|
+
elif msg[0] == 'result':
|
|
1003
|
+
result_data['data'] = msg[1]
|
|
1004
|
+
progress.setValue(100)
|
|
1005
|
+
progress.close()
|
|
1006
|
+
elif msg[0] == 'error':
|
|
1007
|
+
result_data['error'] = msg[1]
|
|
1008
|
+
progress.close()
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
|
|
1012
|
+
timer = QtCore.QTimer(progress)
|
|
1013
|
+
timer.timeout.connect(poll)
|
|
1014
|
+
timer.start(50)
|
|
1015
|
+
proc.start()
|
|
1016
|
+
progress.exec()
|
|
1017
|
+
timer.stop()
|
|
1018
|
+
proc.join(timeout=10)
|
|
1019
|
+
|
|
1020
|
+
if 'error' in result_data:
|
|
1021
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
1022
|
+
return
|
|
1023
|
+
if 'data' not in result_data:
|
|
1024
|
+
return
|
|
1025
|
+
|
|
1026
|
+
x_col, all_columns = result_data['data']
|
|
1027
|
+
x_array = all_columns[x_col]
|
|
1028
|
+
|
|
1029
|
+
self._x = x_array
|
|
1030
|
+
self._all_columns = all_columns
|
|
1031
|
+
self._x_col_name = x_col
|
|
1032
|
+
self._merged_mode = False
|
|
1033
|
+
|
|
1034
|
+
# Clear existing series and subplots
|
|
1035
|
+
self.series_list.clear()
|
|
1036
|
+
self.subplot_series.clear()
|
|
1037
|
+
self.subplot_series_props.clear()
|
|
1038
|
+
self.subplot_axes_labels.clear()
|
|
1039
|
+
self.subplot_config.clear()
|
|
1040
|
+
self._loaded_files.clear()
|
|
1041
|
+
|
|
1042
|
+
# Build series_list from all_columns (lambdas can't cross process boundary)
|
|
1043
|
+
basename = os.path.basename(blf_path)
|
|
1044
|
+
file_entry = {'name': basename, 'series_indices': []}
|
|
1045
|
+
for name in all_columns:
|
|
1046
|
+
if name == x_col:
|
|
1047
|
+
continue
|
|
1048
|
+
data = all_columns[name]
|
|
1049
|
+
idx = len(self.series_list)
|
|
1050
|
+
self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
|
|
1051
|
+
file_entry['series_indices'].append(idx)
|
|
1052
|
+
self._loaded_files.append(file_entry)
|
|
1053
|
+
self._merge_action.setEnabled(True)
|
|
1054
|
+
|
|
1055
|
+
self.update_plot()
|
|
1056
|
+
self.setWindowTitle(f'Toucan-Plot — {basename}')
|
|
1057
|
+
self.show_series_selector()
|
|
1058
|
+
|
|
1059
|
+
def _load_mf4(self, path):
|
|
1060
|
+
"""Load an MF4 file using a worker process with live progress."""
|
|
1061
|
+
queue = multiprocessing.Queue()
|
|
1062
|
+
proc = multiprocessing.Process(target=mf4_load_worker, args=(path, queue))
|
|
1063
|
+
|
|
1064
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
|
|
1065
|
+
progress.setWindowTitle('Loading')
|
|
1066
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
1067
|
+
progress.setMinimumDuration(0)
|
|
1068
|
+
progress.setValue(0)
|
|
1069
|
+
|
|
1070
|
+
result_data = {}
|
|
1071
|
+
|
|
1072
|
+
def poll():
|
|
1073
|
+
try:
|
|
1074
|
+
while not queue.empty():
|
|
1075
|
+
msg = queue.get_nowait()
|
|
1076
|
+
if msg[0] == 'progress':
|
|
1077
|
+
pct = msg[1]
|
|
1078
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
1079
|
+
if pct >= 0:
|
|
1080
|
+
progress.setValue(pct)
|
|
1081
|
+
if label:
|
|
1082
|
+
progress.setLabelText(label)
|
|
1083
|
+
elif msg[0] == 'result':
|
|
1084
|
+
result_data['data'] = msg[1]
|
|
1085
|
+
progress.setValue(100)
|
|
1086
|
+
progress.close()
|
|
1087
|
+
elif msg[0] == 'error':
|
|
1088
|
+
result_data['error'] = msg[1]
|
|
1089
|
+
progress.close()
|
|
1090
|
+
except Exception:
|
|
1091
|
+
pass
|
|
1092
|
+
|
|
1093
|
+
timer = QtCore.QTimer(progress)
|
|
1094
|
+
timer.timeout.connect(poll)
|
|
1095
|
+
timer.start(50)
|
|
1096
|
+
proc.start()
|
|
1097
|
+
progress.exec()
|
|
1098
|
+
timer.stop()
|
|
1099
|
+
proc.join(timeout=10)
|
|
1100
|
+
|
|
1101
|
+
if 'error' in result_data:
|
|
1102
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
1103
|
+
return
|
|
1104
|
+
if 'data' not in result_data:
|
|
1105
|
+
return
|
|
1106
|
+
|
|
1107
|
+
x_col, all_columns = result_data['data']
|
|
1108
|
+
x_array = all_columns[x_col]
|
|
1109
|
+
|
|
1110
|
+
self._x = x_array
|
|
1111
|
+
self._all_columns = all_columns
|
|
1112
|
+
self._x_col_name = x_col
|
|
1113
|
+
self._merged_mode = False
|
|
1114
|
+
|
|
1115
|
+
# Clear existing series and subplots
|
|
1116
|
+
self.series_list.clear()
|
|
1117
|
+
self.subplot_series.clear()
|
|
1118
|
+
self.subplot_series_props.clear()
|
|
1119
|
+
self.subplot_axes_labels.clear()
|
|
1120
|
+
self.subplot_config.clear()
|
|
1121
|
+
self._loaded_files.clear()
|
|
1122
|
+
|
|
1123
|
+
# Build series_list from all_columns
|
|
1124
|
+
basename = os.path.basename(path)
|
|
1125
|
+
file_entry = {'name': basename, 'series_indices': []}
|
|
1126
|
+
for name in all_columns:
|
|
1127
|
+
if name == x_col:
|
|
1128
|
+
continue
|
|
1129
|
+
data = all_columns[name]
|
|
1130
|
+
idx = len(self.series_list)
|
|
1131
|
+
self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
|
|
1132
|
+
file_entry['series_indices'].append(idx)
|
|
1133
|
+
self._loaded_files.append(file_entry)
|
|
1134
|
+
self._merge_action.setEnabled(True)
|
|
1135
|
+
|
|
1136
|
+
self.update_plot()
|
|
1137
|
+
self.setWindowTitle(f'Toucan-Plot — {basename}')
|
|
1138
|
+
self.show_series_selector()
|
|
1139
|
+
|
|
1140
|
+
def _merge_load_mf4(self, path, prefix):
|
|
1141
|
+
"""Load an MF4 file and append its series (prefixed) to the existing series_list."""
|
|
1142
|
+
queue = multiprocessing.Queue()
|
|
1143
|
+
proc = multiprocessing.Process(target=mf4_load_worker, args=(path, queue))
|
|
1144
|
+
|
|
1145
|
+
progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
|
|
1146
|
+
progress.setWindowTitle('Loading')
|
|
1147
|
+
progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
|
1148
|
+
progress.setMinimumDuration(0)
|
|
1149
|
+
progress.setValue(0)
|
|
1150
|
+
|
|
1151
|
+
result_data = {}
|
|
1152
|
+
|
|
1153
|
+
def poll():
|
|
1154
|
+
try:
|
|
1155
|
+
while not queue.empty():
|
|
1156
|
+
msg = queue.get_nowait()
|
|
1157
|
+
if msg[0] == 'progress':
|
|
1158
|
+
pct = msg[1]
|
|
1159
|
+
label = msg[2] if len(msg) > 2 else ''
|
|
1160
|
+
if pct >= 0:
|
|
1161
|
+
progress.setValue(pct)
|
|
1162
|
+
if label:
|
|
1163
|
+
progress.setLabelText(label)
|
|
1164
|
+
elif msg[0] == 'result':
|
|
1165
|
+
result_data['data'] = msg[1]
|
|
1166
|
+
progress.setValue(100)
|
|
1167
|
+
progress.close()
|
|
1168
|
+
elif msg[0] == 'error':
|
|
1169
|
+
result_data['error'] = msg[1]
|
|
1170
|
+
progress.close()
|
|
1171
|
+
except Exception:
|
|
1172
|
+
pass
|
|
1173
|
+
|
|
1174
|
+
timer = QtCore.QTimer(progress)
|
|
1175
|
+
timer.timeout.connect(poll)
|
|
1176
|
+
timer.start(50)
|
|
1177
|
+
proc.start()
|
|
1178
|
+
progress.exec()
|
|
1179
|
+
timer.stop()
|
|
1180
|
+
proc.join(timeout=10)
|
|
1181
|
+
|
|
1182
|
+
if 'error' in result_data:
|
|
1183
|
+
QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
|
|
1184
|
+
return
|
|
1185
|
+
if 'data' not in result_data:
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
x_col, all_columns = result_data['data']
|
|
1189
|
+
x_array = all_columns[x_col]
|
|
1190
|
+
|
|
1191
|
+
if len(self.series_list) == 0:
|
|
1192
|
+
self._x = x_array
|
|
1193
|
+
self._x_col_name = x_col
|
|
1194
|
+
|
|
1195
|
+
file_entry = {'name': prefix, 'series_indices': []}
|
|
1196
|
+
for name in all_columns:
|
|
1197
|
+
if name == x_col:
|
|
1198
|
+
continue
|
|
1199
|
+
data = all_columns[name]
|
|
1200
|
+
prefixed_name = f'{prefix}: {name}'
|
|
1201
|
+
idx = len(self.series_list)
|
|
1202
|
+
self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
|
|
1203
|
+
file_entry['series_indices'].append(idx)
|
|
1204
|
+
self._loaded_files.append(file_entry)
|
|
1205
|
+
|
|
1206
|
+
# ---- Measure cursors ----
|
|
1207
|
+
|
|
1208
|
+
def _toggle_measure(self, checked: bool):
|
|
1209
|
+
if checked:
|
|
1210
|
+
self._measure_active = True
|
|
1211
|
+
# Place two initial cursors at 1/3 and 2/3 of the visible x range
|
|
1212
|
+
if len(self.current_axes) > 0:
|
|
1213
|
+
xmin, xmax = self.current_axes[0].get_xlim()
|
|
1214
|
+
span = xmax - xmin
|
|
1215
|
+
self._measure_x = [xmin + span * 0.33, xmin + span * 0.66]
|
|
1216
|
+
else:
|
|
1217
|
+
self._measure_x = [0.0, 1.0]
|
|
1218
|
+
self._draw_measure_lines()
|
|
1219
|
+
self._show_measure_dialog()
|
|
1220
|
+
# Connect mouse events
|
|
1221
|
+
cid1 = self.canvas.mpl_connect('button_press_event', self._measure_press)
|
|
1222
|
+
cid2 = self.canvas.mpl_connect('motion_notify_event', self._measure_move)
|
|
1223
|
+
cid3 = self.canvas.mpl_connect('button_release_event', self._measure_release)
|
|
1224
|
+
self._measure_cids = [cid1, cid2, cid3]
|
|
1225
|
+
else:
|
|
1226
|
+
self._measure_active = False
|
|
1227
|
+
# Disconnect events
|
|
1228
|
+
for cid in self._measure_cids:
|
|
1229
|
+
self.canvas.mpl_disconnect(cid)
|
|
1230
|
+
self._measure_cids.clear()
|
|
1231
|
+
self._remove_measure_lines()
|
|
1232
|
+
if self._measure_dialog is not None:
|
|
1233
|
+
self._measure_dialog.close()
|
|
1234
|
+
self._measure_dialog = None
|
|
1235
|
+
self.canvas.draw_idle()
|
|
1236
|
+
|
|
1237
|
+
def _draw_measure_lines(self):
|
|
1238
|
+
self._remove_measure_lines()
|
|
1239
|
+
self._measure_lines = []
|
|
1240
|
+
for ax in self.current_axes:
|
|
1241
|
+
lines_for_ax = []
|
|
1242
|
+
for i, xpos in enumerate(self._measure_x):
|
|
1243
|
+
color = 'red' if i == 0 else 'blue'
|
|
1244
|
+
line = ax.axvline(x=xpos, color=color, linestyle='--', linewidth=1.0, picker=5)
|
|
1245
|
+
lines_for_ax.append(line)
|
|
1246
|
+
self._measure_lines.append(lines_for_ax)
|
|
1247
|
+
self.canvas.draw_idle()
|
|
1248
|
+
|
|
1249
|
+
def _remove_measure_lines(self):
|
|
1250
|
+
for lines_for_ax in self._measure_lines:
|
|
1251
|
+
for line in lines_for_ax:
|
|
1252
|
+
try:
|
|
1253
|
+
line.remove()
|
|
1254
|
+
except Exception:
|
|
1255
|
+
pass
|
|
1256
|
+
self._measure_lines.clear()
|
|
1257
|
+
|
|
1258
|
+
def _measure_press(self, event):
|
|
1259
|
+
if event.inaxes is None or event.button != 1:
|
|
1260
|
+
return
|
|
1261
|
+
# Don't drag measure cursors when zoom or pan is active
|
|
1262
|
+
toolbar_mode = getattr(self.nav_toolbar, 'mode', '')
|
|
1263
|
+
if toolbar_mode:
|
|
1264
|
+
return
|
|
1265
|
+
# Find closest cursor
|
|
1266
|
+
best_idx = None
|
|
1267
|
+
best_dist = float('inf')
|
|
1268
|
+
for i, xpos in enumerate(self._measure_x):
|
|
1269
|
+
dist = abs(event.xdata - xpos)
|
|
1270
|
+
if dist < best_dist:
|
|
1271
|
+
best_dist = dist
|
|
1272
|
+
best_idx = i
|
|
1273
|
+
# Only start drag if close enough (within 2% of x range)
|
|
1274
|
+
if len(self.current_axes) > 0:
|
|
1275
|
+
xmin, xmax = self.current_axes[0].get_xlim()
|
|
1276
|
+
threshold = (xmax - xmin) * 0.02
|
|
1277
|
+
else:
|
|
1278
|
+
threshold = best_dist + 1
|
|
1279
|
+
if best_dist <= threshold:
|
|
1280
|
+
self._measure_dragging = best_idx
|
|
1281
|
+
|
|
1282
|
+
def _measure_move(self, event):
|
|
1283
|
+
if self._measure_dragging is None or event.xdata is None:
|
|
1284
|
+
return
|
|
1285
|
+
self._measure_x[self._measure_dragging] = event.xdata
|
|
1286
|
+
self._draw_measure_lines()
|
|
1287
|
+
self._update_measure_dialog()
|
|
1288
|
+
|
|
1289
|
+
def _measure_release(self, event):
|
|
1290
|
+
if self._measure_dragging is not None:
|
|
1291
|
+
self._measure_dragging = None
|
|
1292
|
+
|
|
1293
|
+
def _show_measure_dialog(self):
|
|
1294
|
+
if self._measure_dialog is not None:
|
|
1295
|
+
self._measure_dialog.close()
|
|
1296
|
+
dlg = QtWidgets.QDialog(self)
|
|
1297
|
+
dlg.setWindowTitle('Measure Values')
|
|
1298
|
+
dlg.setMinimumWidth(420)
|
|
1299
|
+
dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
|
1300
|
+
layout = QtWidgets.QVBoxLayout(dlg)
|
|
1301
|
+
self._measure_table = QtWidgets.QTableWidget()
|
|
1302
|
+
layout.addWidget(self._measure_table)
|
|
1303
|
+
dlg.setLayout(layout)
|
|
1304
|
+
self._measure_dialog = dlg
|
|
1305
|
+
dlg.show()
|
|
1306
|
+
self._update_measure_dialog()
|
|
1307
|
+
|
|
1308
|
+
def _update_measure_dialog(self):
|
|
1309
|
+
if self._measure_dialog is None or not self._measure_dialog.isVisible():
|
|
1310
|
+
return
|
|
1311
|
+
x1, x2 = self._measure_x
|
|
1312
|
+
f = float(self.default_freq)
|
|
1313
|
+
table = self._measure_table
|
|
1314
|
+
# Columns: Series | Cursor 1 (x1) | Cursor 2 (x2) | Delta
|
|
1315
|
+
table.setColumnCount(4)
|
|
1316
|
+
table.setHorizontalHeaderLabels([
|
|
1317
|
+
'Series',
|
|
1318
|
+
f'Cursor 1 (x={x1:.4f})',
|
|
1319
|
+
f'Cursor 2 (x={x2:.4f})',
|
|
1320
|
+
'Delta',
|
|
1321
|
+
])
|
|
1322
|
+
# Collect all plotted series across subplots
|
|
1323
|
+
rows = []
|
|
1324
|
+
for subplot_indices in self.subplot_series:
|
|
1325
|
+
for series_idx in subplot_indices:
|
|
1326
|
+
name, func, x_data = self.series_list[series_idx]
|
|
1327
|
+
y_data = func(x_data, f)
|
|
1328
|
+
y1 = float(np.interp(x1, x_data, y_data))
|
|
1329
|
+
y2 = float(np.interp(x2, x_data, y_data))
|
|
1330
|
+
rows.append((name, y1, y2, y2 - y1))
|
|
1331
|
+
table.setRowCount(len(rows))
|
|
1332
|
+
for r, (name, y1, y2, delta) in enumerate(rows):
|
|
1333
|
+
table.setItem(r, 0, QtWidgets.QTableWidgetItem(name))
|
|
1334
|
+
table.setItem(r, 1, QtWidgets.QTableWidgetItem(f'{y1:.6f}'))
|
|
1335
|
+
table.setItem(r, 2, QtWidgets.QTableWidgetItem(f'{y2:.6f}'))
|
|
1336
|
+
table.setItem(r, 3, QtWidgets.QTableWidgetItem(f'{delta:.6f}'))
|
|
1337
|
+
table.resizeColumnsToContents()
|
|
1338
|
+
|
|
1339
|
+
def show_series_selector(self):
|
|
1340
|
+
"""Open the two-pane series editor to create a new subplot.
|
|
1341
|
+
|
|
1342
|
+
The dialog shows Available series (left) and Plotted series (right).
|
|
1343
|
+
When OK is pressed the plotted list is appended as a new subplot.
|
|
1344
|
+
"""
|
|
1345
|
+
def _on_accept(plotted_indices, props, axes_labels):
|
|
1346
|
+
if plotted_indices:
|
|
1347
|
+
self.subplot_series.append(list(plotted_indices))
|
|
1348
|
+
# store props (make a shallow copy)
|
|
1349
|
+
self.subplot_series_props.append([dict(p) for p in props])
|
|
1350
|
+
# store axes labels
|
|
1351
|
+
self.subplot_axes_labels.append(dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
|
|
1352
|
+
self.subplot_config.append(self._make_default_subplot_config())
|
|
1353
|
+
self.update_plot()
|
|
1354
|
+
|
|
1355
|
+
# For a new subplot, initial plotted list is empty
|
|
1356
|
+
self._open_series_editor(initial_plotted=[], on_accept=_on_accept, initial_props=[], initial_axes={})
|
|
1357
|
+
|
|
1358
|
+
def _open_series_editor(self, initial_plotted, on_accept, initial_props=None, initial_axes=None):
|
|
1359
|
+
"""Open a modal editor to select series for a subplot.
|
|
1360
|
+
|
|
1361
|
+
When multiple files are loaded, the available series panel shows one tab per file.
|
|
1362
|
+
The plotted series list on the right is always a single unified list.
|
|
1363
|
+
|
|
1364
|
+
initial_plotted: list of series indices that should appear on the right (plotted)
|
|
1365
|
+
on_accept: callable(plotted_indices_list, props_list, axes_labels) called when OK pressed
|
|
1366
|
+
initial_props: optional list of dicts describing properties for each plotted series (aligned)
|
|
1367
|
+
"""
|
|
1368
|
+
if initial_props is None:
|
|
1369
|
+
initial_props = []
|
|
1370
|
+
if initial_axes is None:
|
|
1371
|
+
initial_axes = {}
|
|
1372
|
+
|
|
1373
|
+
dlg = QtWidgets.QDialog(self)
|
|
1374
|
+
dlg.setWindowTitle('Edit series for subplot')
|
|
1375
|
+
dlg.setModal(True)
|
|
1376
|
+
dlg.resize(700, 420)
|
|
1377
|
+
|
|
1378
|
+
main_layout = QtWidgets.QVBoxLayout(dlg)
|
|
1379
|
+
|
|
1380
|
+
# --- Series: two-list editor ---
|
|
1381
|
+
series_layout = QtWidgets.QHBoxLayout()
|
|
1382
|
+
main_layout.addLayout(series_layout)
|
|
1383
|
+
|
|
1384
|
+
# Build per-file tab structure for available series (left side)
|
|
1385
|
+
avail_layout = QtWidgets.QVBoxLayout()
|
|
1386
|
+
series_layout.addLayout(avail_layout)
|
|
1387
|
+
avail_label = QtWidgets.QLabel('Available series')
|
|
1388
|
+
avail_layout.addWidget(avail_label)
|
|
1389
|
+
|
|
1390
|
+
# Map from series index -> which file index it belongs to (for returning items)
|
|
1391
|
+
series_to_file = {}
|
|
1392
|
+
for file_idx, fentry in enumerate(self._loaded_files):
|
|
1393
|
+
for sidx in fentry['series_indices']:
|
|
1394
|
+
series_to_file[sidx] = file_idx
|
|
1395
|
+
|
|
1396
|
+
plotted_set = set(initial_plotted)
|
|
1397
|
+
|
|
1398
|
+
# Build file tabs (or single list if only one file)
|
|
1399
|
+
use_tabs = len(self._loaded_files) > 1
|
|
1400
|
+
avail_tabs = None
|
|
1401
|
+
avail_lists = [] # parallel to _loaded_files
|
|
1402
|
+
avail_filters = []
|
|
1403
|
+
|
|
1404
|
+
if use_tabs:
|
|
1405
|
+
avail_tabs = QtWidgets.QTabWidget()
|
|
1406
|
+
avail_layout.addWidget(avail_tabs)
|
|
1407
|
+
for file_idx, fentry in enumerate(self._loaded_files):
|
|
1408
|
+
tab_widget = QtWidgets.QWidget()
|
|
1409
|
+
tab_layout = QtWidgets.QVBoxLayout(tab_widget)
|
|
1410
|
+
tab_layout.setContentsMargins(2, 2, 2, 2)
|
|
1411
|
+
filt = QtWidgets.QLineEdit()
|
|
1412
|
+
filt.setPlaceholderText('Filter...')
|
|
1413
|
+
filt.setClearButtonEnabled(True)
|
|
1414
|
+
tab_layout.addWidget(filt)
|
|
1415
|
+
lst = QtWidgets.QListWidget()
|
|
1416
|
+
lst.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
|
|
1417
|
+
tab_layout.addWidget(lst)
|
|
1418
|
+
avail_tabs.addTab(tab_widget, fentry['name'])
|
|
1419
|
+
avail_lists.append(lst)
|
|
1420
|
+
avail_filters.append(filt)
|
|
1421
|
+
|
|
1422
|
+
# Populate this tab's list with the file's series (excluding already plotted)
|
|
1423
|
+
for sidx in fentry['series_indices']:
|
|
1424
|
+
name = self.series_list[sidx][0]
|
|
1425
|
+
item = QtWidgets.QListWidgetItem(name)
|
|
1426
|
+
item.setData(QtCore.Qt.ItemDataRole.UserRole, sidx)
|
|
1427
|
+
if sidx not in plotted_set:
|
|
1428
|
+
lst.addItem(item)
|
|
1429
|
+
|
|
1430
|
+
# Connect filter
|
|
1431
|
+
def make_filter_handler(list_widget):
|
|
1432
|
+
def handler(text):
|
|
1433
|
+
text_lower = text.lower()
|
|
1434
|
+
for i in range(list_widget.count()):
|
|
1435
|
+
it = list_widget.item(i)
|
|
1436
|
+
it.setHidden(text_lower not in it.text().lower())
|
|
1437
|
+
return handler
|
|
1438
|
+
filt.textChanged.connect(make_filter_handler(lst))
|
|
1439
|
+
|
|
1440
|
+
# Connect double-click to add
|
|
1441
|
+
def make_dblclick_handler(list_widget):
|
|
1442
|
+
def handler(item):
|
|
1443
|
+
item.setSelected(True)
|
|
1444
|
+
move_selected_from(list_widget)
|
|
1445
|
+
return handler
|
|
1446
|
+
lst.itemDoubleClicked.connect(make_dblclick_handler(lst))
|
|
1447
|
+
else:
|
|
1448
|
+
# Single file or no files — flat list as before
|
|
1449
|
+
avail_filter = QtWidgets.QLineEdit()
|
|
1450
|
+
avail_filter.setPlaceholderText('Filter...')
|
|
1451
|
+
avail_filter.setClearButtonEnabled(True)
|
|
1452
|
+
avail_layout.addWidget(avail_filter)
|
|
1453
|
+
avail_list = QtWidgets.QListWidget()
|
|
1454
|
+
avail_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
|
|
1455
|
+
avail_layout.addWidget(avail_list)
|
|
1456
|
+
avail_lists.append(avail_list)
|
|
1457
|
+
avail_filters.append(avail_filter)
|
|
1458
|
+
|
|
1459
|
+
for idx, (name, *_rest) in enumerate(self.series_list):
|
|
1460
|
+
item = QtWidgets.QListWidgetItem(name)
|
|
1461
|
+
item.setData(QtCore.Qt.ItemDataRole.UserRole, idx)
|
|
1462
|
+
if idx not in plotted_set:
|
|
1463
|
+
avail_list.addItem(item)
|
|
1464
|
+
|
|
1465
|
+
def on_filter_changed(text):
|
|
1466
|
+
text_lower = text.lower()
|
|
1467
|
+
for i in range(avail_list.count()):
|
|
1468
|
+
it = avail_list.item(i)
|
|
1469
|
+
it.setHidden(text_lower not in it.text().lower())
|
|
1470
|
+
avail_filter.textChanged.connect(on_filter_changed)
|
|
1471
|
+
|
|
1472
|
+
# --- Expression evaluator ---
|
|
1473
|
+
# Track last selected series name across all available lists
|
|
1474
|
+
last_selected_name = {'value': ''}
|
|
1475
|
+
|
|
1476
|
+
def on_avail_selection_changed():
|
|
1477
|
+
"""Track the last selected item from any available list."""
|
|
1478
|
+
for lst in avail_lists:
|
|
1479
|
+
selected = lst.selectedItems()
|
|
1480
|
+
if selected:
|
|
1481
|
+
last_selected_name['value'] = selected[-1].text()
|
|
1482
|
+
|
|
1483
|
+
for lst in avail_lists:
|
|
1484
|
+
lst.itemSelectionChanged.connect(on_avail_selection_changed)
|
|
1485
|
+
|
|
1486
|
+
copy_to_expr_btn = QtWidgets.QPushButton('Copy selected to expression')
|
|
1487
|
+
copy_to_expr_btn.setToolTip('Insert the last selected series name into the expression field')
|
|
1488
|
+
avail_layout.addWidget(copy_to_expr_btn)
|
|
1489
|
+
|
|
1490
|
+
expr_layout = QtWidgets.QHBoxLayout()
|
|
1491
|
+
avail_layout.addLayout(expr_layout)
|
|
1492
|
+
expr_field = QtWidgets.QLineEdit()
|
|
1493
|
+
expr_field.setPlaceholderText('e.g. sin("series_A") + "series_B" * 2')
|
|
1494
|
+
expr_field.setToolTip(
|
|
1495
|
+
'Math expression using series names in "quotes".\n'
|
|
1496
|
+
'Supported: +, -, *, /, **, abs(), sin(), cos(), tan(),\n'
|
|
1497
|
+
'sqrt(), exp(), log(), pi, e'
|
|
1498
|
+
)
|
|
1499
|
+
expr_layout.addWidget(expr_field)
|
|
1500
|
+
|
|
1501
|
+
add_expr_btn = QtWidgets.QPushButton('Add')
|
|
1502
|
+
add_expr_btn.setToolTip('Evaluate expression and add as a new plotted series')
|
|
1503
|
+
expr_layout.addWidget(add_expr_btn)
|
|
1504
|
+
|
|
1505
|
+
def copy_selected_to_expr():
|
|
1506
|
+
name = last_selected_name['value']
|
|
1507
|
+
if name:
|
|
1508
|
+
current = expr_field.text()
|
|
1509
|
+
cursor_pos = expr_field.cursorPosition()
|
|
1510
|
+
# If the name already contains quotes, it's an expression — insert as-is
|
|
1511
|
+
if '"' in name:
|
|
1512
|
+
token = name
|
|
1513
|
+
else:
|
|
1514
|
+
token = '"' + name + '"'
|
|
1515
|
+
new_text = current[:cursor_pos] + token + current[cursor_pos:]
|
|
1516
|
+
expr_field.setText(new_text)
|
|
1517
|
+
expr_field.setCursorPosition(cursor_pos + len(token))
|
|
1518
|
+
expr_field.setFocus()
|
|
1519
|
+
|
|
1520
|
+
copy_to_expr_btn.clicked.connect(copy_selected_to_expr)
|
|
1521
|
+
|
|
1522
|
+
def add_expression():
|
|
1523
|
+
expr_text = expr_field.text().strip()
|
|
1524
|
+
if not expr_text:
|
|
1525
|
+
return
|
|
1526
|
+
|
|
1527
|
+
# Build a mapping of series name -> (data_array, x_array)
|
|
1528
|
+
series_map = {}
|
|
1529
|
+
for idx_s, (sname, sfunc, sx) in enumerate(self.series_list):
|
|
1530
|
+
y_data = sfunc(sx, float(self.default_freq))
|
|
1531
|
+
series_map[sname] = (y_data, sx)
|
|
1532
|
+
|
|
1533
|
+
# Replace "series_name" tokens with placeholder variable names
|
|
1534
|
+
import re
|
|
1535
|
+
token_pattern = re.compile(r'"([^"]+)"')
|
|
1536
|
+
tokens_found = token_pattern.findall(expr_text)
|
|
1537
|
+
|
|
1538
|
+
if not tokens_found:
|
|
1539
|
+
QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
|
|
1540
|
+
'No series references found.\nUse "series_name" to reference a series.')
|
|
1541
|
+
return
|
|
1542
|
+
|
|
1543
|
+
# Validate all referenced series exist
|
|
1544
|
+
for tok in tokens_found:
|
|
1545
|
+
if tok not in series_map:
|
|
1546
|
+
QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
|
|
1547
|
+
f'Series not found: {tok}')
|
|
1548
|
+
return
|
|
1549
|
+
|
|
1550
|
+
# Determine x_data: use the x from the first referenced series
|
|
1551
|
+
first_ref = tokens_found[0]
|
|
1552
|
+
expr_x = series_map[first_ref][1]
|
|
1553
|
+
|
|
1554
|
+
# For series with different x-axes, interpolate onto the first one
|
|
1555
|
+
var_arrays = {}
|
|
1556
|
+
for i, tok in enumerate(tokens_found):
|
|
1557
|
+
y_data, tok_x = series_map[tok]
|
|
1558
|
+
var_name = f'_s{i}'
|
|
1559
|
+
if tok_x is expr_x or (len(tok_x) == len(expr_x) and np.allclose(tok_x, expr_x)):
|
|
1560
|
+
var_arrays[var_name] = y_data
|
|
1561
|
+
else:
|
|
1562
|
+
var_arrays[var_name] = np.interp(expr_x, tok_x, y_data)
|
|
1563
|
+
|
|
1564
|
+
# Build the evaluable expression by replacing tokens with variable names
|
|
1565
|
+
eval_expr = expr_text
|
|
1566
|
+
for i, tok in enumerate(tokens_found):
|
|
1567
|
+
eval_expr = eval_expr.replace('"' + tok + '"', f'_s{i}')
|
|
1568
|
+
|
|
1569
|
+
# Safe math namespace
|
|
1570
|
+
safe_ns = {
|
|
1571
|
+
'__builtins__': {},
|
|
1572
|
+
'sin': np.sin, 'cos': np.cos, 'tan': np.tan,
|
|
1573
|
+
'abs': np.abs, 'sqrt': np.sqrt, 'exp': np.exp,
|
|
1574
|
+
'log': np.log, 'log10': np.log10,
|
|
1575
|
+
'pi': np.pi, 'e': np.e,
|
|
1576
|
+
'arcsin': np.arcsin, 'arccos': np.arccos, 'arctan': np.arctan,
|
|
1577
|
+
'clip': np.clip, 'sign': np.sign,
|
|
1578
|
+
}
|
|
1579
|
+
safe_ns.update(var_arrays)
|
|
1580
|
+
|
|
1581
|
+
try:
|
|
1582
|
+
result = eval(eval_expr, safe_ns) # noqa: S307
|
|
1583
|
+
result = np.asarray(result, dtype=float)
|
|
1584
|
+
if result.shape != expr_x.shape:
|
|
1585
|
+
result = np.broadcast_to(result, expr_x.shape).copy()
|
|
1586
|
+
except Exception as exc:
|
|
1587
|
+
QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
|
|
1588
|
+
f'Failed to evaluate expression:\n{exc}')
|
|
1589
|
+
return
|
|
1590
|
+
|
|
1591
|
+
# Create a new computed series and add to series_list
|
|
1592
|
+
expr_label = expr_text
|
|
1593
|
+
new_idx = len(self.series_list)
|
|
1594
|
+
self.series_list.append((expr_label, lambda x, f, _d=result: _d, expr_x))
|
|
1595
|
+
|
|
1596
|
+
# Track in loaded files (add to a virtual "Expressions" file entry)
|
|
1597
|
+
expr_file = None
|
|
1598
|
+
for fe in self._loaded_files:
|
|
1599
|
+
if fe['name'] == '(Expressions)':
|
|
1600
|
+
expr_file = fe
|
|
1601
|
+
break
|
|
1602
|
+
if expr_file is None:
|
|
1603
|
+
expr_file = {'name': '(Expressions)', 'series_indices': []}
|
|
1604
|
+
self._loaded_files.append(expr_file)
|
|
1605
|
+
expr_file['series_indices'].append(new_idx)
|
|
1606
|
+
series_to_file[new_idx] = self._loaded_files.index(expr_file)
|
|
1607
|
+
|
|
1608
|
+
# Also add to the tabs if using tabs (add a new tab if needed)
|
|
1609
|
+
if use_tabs and avail_tabs is not None:
|
|
1610
|
+
expr_tab_idx = None
|
|
1611
|
+
for ti in range(len(avail_lists)):
|
|
1612
|
+
if ti < len(self._loaded_files) and self._loaded_files[ti]['name'] == '(Expressions)':
|
|
1613
|
+
expr_tab_idx = ti
|
|
1614
|
+
break
|
|
1615
|
+
if expr_tab_idx is None or expr_tab_idx >= len(avail_lists):
|
|
1616
|
+
# Create a new tab for expressions
|
|
1617
|
+
tab_widget = QtWidgets.QWidget()
|
|
1618
|
+
tab_lay = QtWidgets.QVBoxLayout(tab_widget)
|
|
1619
|
+
tab_lay.setContentsMargins(2, 2, 2, 2)
|
|
1620
|
+
filt = QtWidgets.QLineEdit()
|
|
1621
|
+
filt.setPlaceholderText('Filter...')
|
|
1622
|
+
filt.setClearButtonEnabled(True)
|
|
1623
|
+
tab_lay.addWidget(filt)
|
|
1624
|
+
lst = QtWidgets.QListWidget()
|
|
1625
|
+
lst.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
|
|
1626
|
+
tab_lay.addWidget(lst)
|
|
1627
|
+
avail_tabs.addTab(tab_widget, '(Expressions)')
|
|
1628
|
+
avail_lists.append(lst)
|
|
1629
|
+
avail_filters.append(filt)
|
|
1630
|
+
filt.textChanged.connect(make_filter_handler(lst))
|
|
1631
|
+
lst.itemDoubleClicked.connect(make_dblclick_handler(lst))
|
|
1632
|
+
lst.itemSelectionChanged.connect(on_avail_selection_changed)
|
|
1633
|
+
expr_tab_idx = len(avail_lists) - 1
|
|
1634
|
+
|
|
1635
|
+
# Add directly to plotted list
|
|
1636
|
+
item = QtWidgets.QListWidgetItem(expr_label)
|
|
1637
|
+
item.setData(QtCore.Qt.ItemDataRole.UserRole, new_idx)
|
|
1638
|
+
plotted_list.addItem(item)
|
|
1639
|
+
|
|
1640
|
+
expr_field.clear()
|
|
1641
|
+
|
|
1642
|
+
add_expr_btn.clicked.connect(add_expression)
|
|
1643
|
+
|
|
1644
|
+
# Buttons between lists
|
|
1645
|
+
btns_layout = QtWidgets.QVBoxLayout()
|
|
1646
|
+
series_layout.addLayout(btns_layout)
|
|
1647
|
+
btns_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
1648
|
+
add_btn = QtWidgets.QPushButton('>>')
|
|
1649
|
+
remove_btn = QtWidgets.QPushButton('<<')
|
|
1650
|
+
add_all_btn = QtWidgets.QPushButton('All >>')
|
|
1651
|
+
remove_all_btn = QtWidgets.QPushButton('<< All')
|
|
1652
|
+
btns_layout.addWidget(add_btn)
|
|
1653
|
+
btns_layout.addWidget(remove_btn)
|
|
1654
|
+
btns_layout.addSpacing(10)
|
|
1655
|
+
btns_layout.addWidget(add_all_btn)
|
|
1656
|
+
btns_layout.addWidget(remove_all_btn)
|
|
1657
|
+
|
|
1658
|
+
# Plotted list (right)
|
|
1659
|
+
plotted_layout = QtWidgets.QVBoxLayout()
|
|
1660
|
+
series_layout.addLayout(plotted_layout)
|
|
1661
|
+
plotted_label = QtWidgets.QLabel('Plotted series')
|
|
1662
|
+
plotted_layout.addWidget(plotted_label)
|
|
1663
|
+
plotted_list = QtWidgets.QListWidget()
|
|
1664
|
+
plotted_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
|
1665
|
+
plotted_layout.addWidget(plotted_list)
|
|
1666
|
+
|
|
1667
|
+
# Populate plotted list with initial_plotted items
|
|
1668
|
+
for sidx in initial_plotted:
|
|
1669
|
+
name = self.series_list[sidx][0]
|
|
1670
|
+
item = QtWidgets.QListWidgetItem(name)
|
|
1671
|
+
item.setData(QtCore.Qt.ItemDataRole.UserRole, sidx)
|
|
1672
|
+
plotted_list.addItem(item)
|
|
1673
|
+
|
|
1674
|
+
# --- Move helpers ---
|
|
1675
|
+
def get_active_avail_list():
|
|
1676
|
+
"""Return the currently visible available list."""
|
|
1677
|
+
if use_tabs and avail_tabs is not None:
|
|
1678
|
+
idx = avail_tabs.currentIndex()
|
|
1679
|
+
if 0 <= idx < len(avail_lists):
|
|
1680
|
+
return avail_lists[idx]
|
|
1681
|
+
return avail_lists[0] if avail_lists else None
|
|
1682
|
+
|
|
1683
|
+
def move_selected_from(src: QtWidgets.QListWidget):
|
|
1684
|
+
"""Move selected items from an available list to the plotted list."""
|
|
1685
|
+
items = src.selectedItems()
|
|
1686
|
+
for it in items:
|
|
1687
|
+
row = src.row(it)
|
|
1688
|
+
src.takeItem(row)
|
|
1689
|
+
plotted_list.addItem(it)
|
|
1690
|
+
|
|
1691
|
+
def _avail_list_has_index(lst, sidx):
|
|
1692
|
+
"""Check if a QListWidget already contains an item with the given series index."""
|
|
1693
|
+
for i in range(lst.count()):
|
|
1694
|
+
it = lst.item(i)
|
|
1695
|
+
if it is not None and int(it.data(QtCore.Qt.ItemDataRole.UserRole)) == sidx:
|
|
1696
|
+
return True
|
|
1697
|
+
return False
|
|
1698
|
+
|
|
1699
|
+
def move_selected_to_avail():
|
|
1700
|
+
"""Move selected items from plotted list back to their original file tab."""
|
|
1701
|
+
items = plotted_list.selectedItems()
|
|
1702
|
+
for it in items:
|
|
1703
|
+
row = plotted_list.row(it)
|
|
1704
|
+
plotted_list.takeItem(row)
|
|
1705
|
+
sidx = int(it.data(QtCore.Qt.ItemDataRole.UserRole))
|
|
1706
|
+
file_idx = series_to_file.get(sidx, 0)
|
|
1707
|
+
target = avail_lists[file_idx] if file_idx < len(avail_lists) else (avail_lists[0] if avail_lists else None)
|
|
1708
|
+
if target is not None and not _avail_list_has_index(target, sidx):
|
|
1709
|
+
target.addItem(it)
|
|
1710
|
+
|
|
1711
|
+
def move_all_to_plotted():
|
|
1712
|
+
"""Move all items from the active available list to plotted."""
|
|
1713
|
+
src = get_active_avail_list()
|
|
1714
|
+
if src is None:
|
|
1715
|
+
return
|
|
1716
|
+
while src.count() > 0:
|
|
1717
|
+
it = src.takeItem(0)
|
|
1718
|
+
plotted_list.addItem(it)
|
|
1719
|
+
|
|
1720
|
+
def move_all_to_avail():
|
|
1721
|
+
"""Move all items from plotted list back to their original file tabs."""
|
|
1722
|
+
while plotted_list.count() > 0:
|
|
1723
|
+
it = plotted_list.takeItem(0)
|
|
1724
|
+
sidx = int(it.data(QtCore.Qt.ItemDataRole.UserRole))
|
|
1725
|
+
file_idx = series_to_file.get(sidx, 0)
|
|
1726
|
+
target = avail_lists[file_idx] if file_idx < len(avail_lists) else (avail_lists[0] if avail_lists else None)
|
|
1727
|
+
if target is not None and not _avail_list_has_index(target, sidx):
|
|
1728
|
+
target.addItem(it)
|
|
1729
|
+
|
|
1730
|
+
add_btn.clicked.connect(lambda: move_selected_from(get_active_avail_list()) if get_active_avail_list() else None)
|
|
1731
|
+
remove_btn.clicked.connect(move_selected_to_avail)
|
|
1732
|
+
add_all_btn.clicked.connect(move_all_to_plotted)
|
|
1733
|
+
remove_all_btn.clicked.connect(move_all_to_avail)
|
|
1734
|
+
|
|
1735
|
+
if not use_tabs:
|
|
1736
|
+
# Single-list double-click handlers
|
|
1737
|
+
avail_lists[0].itemDoubleClicked.connect(lambda item: (item.setSelected(True), move_selected_from(avail_lists[0])))
|
|
1738
|
+
|
|
1739
|
+
def on_plotted_double_click(item):
|
|
1740
|
+
item.setSelected(True)
|
|
1741
|
+
move_selected_to_avail()
|
|
1742
|
+
|
|
1743
|
+
plotted_list.itemDoubleClicked.connect(on_plotted_double_click)
|
|
1744
|
+
|
|
1745
|
+
# Dialog buttons
|
|
1746
|
+
dlg_buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel)
|
|
1747
|
+
main_layout.addWidget(dlg_buttons)
|
|
1748
|
+
|
|
1749
|
+
def on_ok():
|
|
1750
|
+
# collect plotted indices in order
|
|
1751
|
+
plotted_indices = []
|
|
1752
|
+
for i in range(plotted_list.count()):
|
|
1753
|
+
it = plotted_list.item(i)
|
|
1754
|
+
idx = it.data(QtCore.Qt.ItemDataRole.UserRole)
|
|
1755
|
+
plotted_indices.append(int(idx))
|
|
1756
|
+
# Preserve existing props where available, create defaults for new series
|
|
1757
|
+
existing_props_map = {}
|
|
1758
|
+
for j, sidx in enumerate(initial_plotted):
|
|
1759
|
+
if j < len(initial_props):
|
|
1760
|
+
existing_props_map[sidx] = initial_props[j]
|
|
1761
|
+
props = []
|
|
1762
|
+
for sidx in plotted_indices:
|
|
1763
|
+
if sidx in existing_props_map:
|
|
1764
|
+
props.append(dict(existing_props_map[sidx]))
|
|
1765
|
+
else:
|
|
1766
|
+
name = self.series_list[sidx][0]
|
|
1767
|
+
props.append({'label': name, 'color': '', 'linewidth': 1.5, 'linestyle': '', 'marker': '', 'yaxis': 1})
|
|
1768
|
+
axes_labels = dict(initial_axes) if initial_axes else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
|
|
1769
|
+
on_accept(plotted_indices, props, axes_labels)
|
|
1770
|
+
dlg.accept()
|
|
1771
|
+
|
|
1772
|
+
def on_cancel():
|
|
1773
|
+
dlg.reject()
|
|
1774
|
+
|
|
1775
|
+
dlg_buttons.accepted.connect(on_ok)
|
|
1776
|
+
dlg_buttons.rejected.connect(on_cancel)
|
|
1777
|
+
|
|
1778
|
+
dlg.exec()
|
|
1779
|
+
|
|
1780
|
+
def on_canvas_click(self, event):
|
|
1781
|
+
"""Handle mouse clicks on the Matplotlib canvas. On double-click inside an axes, open series dialog to add a line to that subplot."""
|
|
1782
|
+
# event.dblclick is True for double clicks
|
|
1783
|
+
if event.inaxes is None:
|
|
1784
|
+
return
|
|
1785
|
+
|
|
1786
|
+
# Find which axes was clicked
|
|
1787
|
+
try:
|
|
1788
|
+
ax_index = self.current_axes.index(event.inaxes)
|
|
1789
|
+
except Exception:
|
|
1790
|
+
return
|
|
1791
|
+
|
|
1792
|
+
# Double-click: edit the subplot's series
|
|
1793
|
+
if getattr(event, 'dblclick', False):
|
|
1794
|
+
def _on_accept(plotted_indices, props, axes_labels):
|
|
1795
|
+
# Replace the subplot's series, properties and axes labels with the returned values
|
|
1796
|
+
if plotted_indices is not None:
|
|
1797
|
+
self.subplot_series[ax_index] = list(plotted_indices)
|
|
1798
|
+
# ensure props list stored
|
|
1799
|
+
if ax_index < len(self.subplot_series_props):
|
|
1800
|
+
self.subplot_series_props[ax_index] = [dict(p) for p in props]
|
|
1801
|
+
else:
|
|
1802
|
+
# extend props list accordingly
|
|
1803
|
+
while len(self.subplot_series_props) < ax_index:
|
|
1804
|
+
self.subplot_series_props.append([])
|
|
1805
|
+
self.subplot_series_props.insert(ax_index, [dict(p) for p in props])
|
|
1806
|
+
# store axes labels
|
|
1807
|
+
if ax_index < len(self.subplot_axes_labels):
|
|
1808
|
+
self.subplot_axes_labels[ax_index] = dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
|
|
1809
|
+
else:
|
|
1810
|
+
while len(self.subplot_axes_labels) < ax_index:
|
|
1811
|
+
self.subplot_axes_labels.append({'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
|
|
1812
|
+
self.subplot_axes_labels.insert(ax_index, dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
|
|
1813
|
+
self.update_plot()
|
|
1814
|
+
|
|
1815
|
+
# provide existing props if available
|
|
1816
|
+
existing_props = []
|
|
1817
|
+
if ax_index < len(self.subplot_series_props):
|
|
1818
|
+
existing_props = self.subplot_series_props[ax_index]
|
|
1819
|
+
|
|
1820
|
+
existing_axes = {}
|
|
1821
|
+
if ax_index < len(self.subplot_axes_labels):
|
|
1822
|
+
existing_axes = self.subplot_axes_labels[ax_index]
|
|
1823
|
+
self._open_series_editor(initial_plotted=self.subplot_series[ax_index], on_accept=_on_accept, initial_props=existing_props, initial_axes=existing_axes)
|
|
1824
|
+
return
|
|
1825
|
+
|
|
1826
|
+
# Right-click: show context menu for subplot actions
|
|
1827
|
+
# Matplotlib's event.guiEvent (for QTAgg) contains the underlying Qt event
|
|
1828
|
+
# But only if zoom/pan is not active
|
|
1829
|
+
if getattr(event, 'button', None) == 3:
|
|
1830
|
+
# Check if zoom or pan mode is active (compatible across Matplotlib versions)
|
|
1831
|
+
toolbar_mode = getattr(self.nav_toolbar, 'mode', '')
|
|
1832
|
+
actions = getattr(self.nav_toolbar, '_actions', {})
|
|
1833
|
+
pan_action = actions.get('pan') if isinstance(actions, dict) else None
|
|
1834
|
+
zoom_action = actions.get('zoom') if isinstance(actions, dict) else None
|
|
1835
|
+
pan_active = bool(pan_action.isChecked()) if pan_action is not None else False
|
|
1836
|
+
zoom_active = bool(zoom_action.isChecked()) if zoom_action is not None else False
|
|
1837
|
+
if toolbar_mode or pan_active or zoom_active:
|
|
1838
|
+
# Zoom or pan is active, don't show context menu
|
|
1839
|
+
return
|
|
1840
|
+
|
|
1841
|
+
# Build a QMenu with actions: Delete subplot, Add above, Add below
|
|
1842
|
+
menu = QtWidgets.QMenu(self)
|
|
1843
|
+
|
|
1844
|
+
delete_action = QtGui.QAction('Delete subplot', self)
|
|
1845
|
+
add_above_action = QtGui.QAction('Add subplot above', self)
|
|
1846
|
+
add_below_action = QtGui.QAction('Add subplot below', self)
|
|
1847
|
+
|
|
1848
|
+
menu.addAction(add_above_action)
|
|
1849
|
+
menu.addAction(add_below_action)
|
|
1850
|
+
menu.addSeparator()
|
|
1851
|
+
|
|
1852
|
+
# --- Legend submenu ---
|
|
1853
|
+
# ensure config exists for this subplot
|
|
1854
|
+
while len(self.subplot_config) <= ax_index:
|
|
1855
|
+
self.subplot_config.append(self._make_default_subplot_config())
|
|
1856
|
+
cur_cfg = self.subplot_config[ax_index]
|
|
1857
|
+
|
|
1858
|
+
legend_menu = menu.addMenu('Legend')
|
|
1859
|
+
|
|
1860
|
+
show_legend_action = QtGui.QAction('Show legend', self)
|
|
1861
|
+
show_legend_action.setCheckable(True)
|
|
1862
|
+
show_legend_action.setChecked(cur_cfg.get('legend_show', True))
|
|
1863
|
+
def toggle_legend(checked):
|
|
1864
|
+
self.subplot_config[ax_index]['legend_show'] = checked
|
|
1865
|
+
self.default_plot_style['legend_show'] = checked
|
|
1866
|
+
self.update_plot()
|
|
1867
|
+
show_legend_action.toggled.connect(toggle_legend)
|
|
1868
|
+
legend_menu.addAction(show_legend_action)
|
|
1869
|
+
|
|
1870
|
+
# Position submenu
|
|
1871
|
+
pos_menu = legend_menu.addMenu('Position')
|
|
1872
|
+
current_pos = cur_cfg.get('legend_pos', 'best')
|
|
1873
|
+
pos_group = QtGui.QActionGroup(pos_menu)
|
|
1874
|
+
for p in LEGEND_POSITIONS:
|
|
1875
|
+
a = QtGui.QAction(p, self)
|
|
1876
|
+
a.setCheckable(True)
|
|
1877
|
+
a.setChecked(p == current_pos)
|
|
1878
|
+
pos_group.addAction(a)
|
|
1879
|
+
pos_menu.addAction(a)
|
|
1880
|
+
def make_pos_handler(pos_val):
|
|
1881
|
+
def handler():
|
|
1882
|
+
self.subplot_config[ax_index]['legend_pos'] = pos_val
|
|
1883
|
+
self.default_plot_style['legend_pos'] = pos_val
|
|
1884
|
+
self.update_plot()
|
|
1885
|
+
return handler
|
|
1886
|
+
a.triggered.connect(make_pos_handler(p))
|
|
1887
|
+
|
|
1888
|
+
# Orientation submenu
|
|
1889
|
+
orient_menu = legend_menu.addMenu('Orientation')
|
|
1890
|
+
current_orient = cur_cfg.get('legend_orient', 'vertical')
|
|
1891
|
+
orient_group = QtGui.QActionGroup(orient_menu)
|
|
1892
|
+
for ori in ('vertical', 'horizontal'):
|
|
1893
|
+
a = QtGui.QAction(ori.capitalize(), self)
|
|
1894
|
+
a.setCheckable(True)
|
|
1895
|
+
a.setChecked(ori == current_orient)
|
|
1896
|
+
orient_group.addAction(a)
|
|
1897
|
+
orient_menu.addAction(a)
|
|
1898
|
+
def make_orient_handler(ori_val):
|
|
1899
|
+
def handler():
|
|
1900
|
+
self.subplot_config[ax_index]['legend_orient'] = ori_val
|
|
1901
|
+
self.default_plot_style['legend_orient'] = ori_val
|
|
1902
|
+
self.update_plot()
|
|
1903
|
+
return handler
|
|
1904
|
+
a.triggered.connect(make_orient_handler(ori))
|
|
1905
|
+
|
|
1906
|
+
# Text size submenu
|
|
1907
|
+
size_menu = legend_menu.addMenu('Text size')
|
|
1908
|
+
current_fontsize = cur_cfg.get('legend_fontsize', 12)
|
|
1909
|
+
size_group = QtGui.QActionGroup(size_menu)
|
|
1910
|
+
for sz in (6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20):
|
|
1911
|
+
a = QtGui.QAction(str(sz), self)
|
|
1912
|
+
a.setCheckable(True)
|
|
1913
|
+
a.setChecked(sz == current_fontsize)
|
|
1914
|
+
size_group.addAction(a)
|
|
1915
|
+
size_menu.addAction(a)
|
|
1916
|
+
def make_size_handler(sz_val):
|
|
1917
|
+
def handler():
|
|
1918
|
+
self.subplot_config[ax_index]['legend_fontsize'] = sz_val
|
|
1919
|
+
self.default_plot_style['legend_fontsize'] = sz_val
|
|
1920
|
+
self.update_plot()
|
|
1921
|
+
return handler
|
|
1922
|
+
a.triggered.connect(make_size_handler(sz))
|
|
1923
|
+
|
|
1924
|
+
# --- Plot style submenu ---
|
|
1925
|
+
plot_style_line_menu = menu.addMenu('Plot style')
|
|
1926
|
+
current_mode = cur_cfg.get('plot_mode', 'step')
|
|
1927
|
+
mode_group = QtGui.QActionGroup(plot_style_line_menu)
|
|
1928
|
+
for mode in ('plot', 'step'):
|
|
1929
|
+
a = QtGui.QAction(mode.capitalize(), self)
|
|
1930
|
+
a.setCheckable(True)
|
|
1931
|
+
a.setChecked(mode == current_mode)
|
|
1932
|
+
mode_group.addAction(a)
|
|
1933
|
+
plot_style_line_menu.addAction(a)
|
|
1934
|
+
def make_mode_handler(mode_val):
|
|
1935
|
+
def handler():
|
|
1936
|
+
self.subplot_config[ax_index]['plot_mode'] = mode_val
|
|
1937
|
+
self.default_plot_style['plot_mode'] = mode_val
|
|
1938
|
+
self.update_plot()
|
|
1939
|
+
return handler
|
|
1940
|
+
a.triggered.connect(make_mode_handler(mode))
|
|
1941
|
+
|
|
1942
|
+
menu.addSeparator()
|
|
1943
|
+
menu.addAction(delete_action)
|
|
1944
|
+
|
|
1945
|
+
# Helper to get global position robustly across PyQt/Matplotlib versions
|
|
1946
|
+
pos = None
|
|
1947
|
+
ge = getattr(event, 'guiEvent', None)
|
|
1948
|
+
if ge is not None:
|
|
1949
|
+
# Prefer globalPosition (QPointF) if available, else globalPos(), else map from local position
|
|
1950
|
+
try:
|
|
1951
|
+
gp = ge.globalPosition()
|
|
1952
|
+
# QPointF -> QPoint
|
|
1953
|
+
pos = QtCore.QPoint(int(gp.x()), int(gp.y()))
|
|
1954
|
+
except Exception:
|
|
1955
|
+
try:
|
|
1956
|
+
gp = ge.globalPos()
|
|
1957
|
+
pos = gp
|
|
1958
|
+
except Exception:
|
|
1959
|
+
try:
|
|
1960
|
+
# fallback to local position on the widget
|
|
1961
|
+
lp = ge.position()
|
|
1962
|
+
pos = self.canvas.mapToGlobal(QtCore.QPoint(int(lp.x()), int(lp.y())))
|
|
1963
|
+
except Exception:
|
|
1964
|
+
pos = None
|
|
1965
|
+
if pos is None:
|
|
1966
|
+
# Final fallback: map matplotlib event display coords to global
|
|
1967
|
+
try:
|
|
1968
|
+
# event.x, event.y are display coords (floats); map directly
|
|
1969
|
+
pos = self.canvas.mapToGlobal(QtCore.QPoint(int(event.x), int(event.y)))
|
|
1970
|
+
except Exception:
|
|
1971
|
+
pos = None
|
|
1972
|
+
|
|
1973
|
+
# Action handlers
|
|
1974
|
+
def do_delete():
|
|
1975
|
+
# remove the subplot at ax_index
|
|
1976
|
+
if 0 <= ax_index < len(self.subplot_series):
|
|
1977
|
+
self.subplot_series.pop(ax_index)
|
|
1978
|
+
# also remove properties if present
|
|
1979
|
+
if ax_index < len(self.subplot_series_props):
|
|
1980
|
+
self.subplot_series_props.pop(ax_index)
|
|
1981
|
+
if ax_index < len(self.subplot_axes_labels):
|
|
1982
|
+
self.subplot_axes_labels.pop(ax_index)
|
|
1983
|
+
if ax_index < len(self.subplot_config):
|
|
1984
|
+
self.subplot_config.pop(ax_index)
|
|
1985
|
+
self.update_plot()
|
|
1986
|
+
|
|
1987
|
+
def do_add(insert_pos):
|
|
1988
|
+
# Open series editor to choose initial plotted series for new subplot
|
|
1989
|
+
def _on_accept(plotted_indices, props, axes_labels):
|
|
1990
|
+
if plotted_indices is None:
|
|
1991
|
+
return
|
|
1992
|
+
# Insert the new subplot at insert_pos
|
|
1993
|
+
self.subplot_series.insert(insert_pos, list(plotted_indices))
|
|
1994
|
+
# insert props
|
|
1995
|
+
self.subplot_series_props.insert(insert_pos, [dict(p) for p in props])
|
|
1996
|
+
# insert axes labels
|
|
1997
|
+
self.subplot_axes_labels.insert(insert_pos, dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
|
|
1998
|
+
self.subplot_config.insert(insert_pos, self._make_default_subplot_config())
|
|
1999
|
+
self.update_plot()
|
|
2000
|
+
|
|
2001
|
+
# Call editor
|
|
2002
|
+
self._open_series_editor(initial_plotted=[], on_accept=_on_accept, initial_props=[], initial_axes={})
|
|
2003
|
+
|
|
2004
|
+
delete_action.triggered.connect(do_delete)
|
|
2005
|
+
add_above_action.triggered.connect(lambda: do_add(ax_index))
|
|
2006
|
+
add_below_action.triggered.connect(lambda: do_add(ax_index + 1))
|
|
2007
|
+
|
|
2008
|
+
# Show the menu at the computed position
|
|
2009
|
+
if pos is not None:
|
|
2010
|
+
menu.exec(pos)
|
|
2011
|
+
else:
|
|
2012
|
+
menu.exec(self.mapToGlobal(self.rect().center()))
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def main():
|
|
2016
|
+
import argparse
|
|
2017
|
+
parser = argparse.ArgumentParser(
|
|
2018
|
+
prog='toucan-plot',
|
|
2019
|
+
description='Toucan-Plot — Interactive plotting tool for CSV, SMV, and CAN log (BLF/TRC/ASC) files.',
|
|
2020
|
+
epilog='BLF, TRC, and ASC files require at least one DBC file for signal decoding.\n'
|
|
2021
|
+
'Example: toucan-plot log.blf signals.dbc',
|
|
2022
|
+
)
|
|
2023
|
+
parser.add_argument(
|
|
2024
|
+
'files', nargs='*', metavar='FILE',
|
|
2025
|
+
help='Data files to open on startup.\nCSV and SMV files are loaded directly.\n'
|
|
2026
|
+
'BLF, TRC, and ASC files must be provided together with one or more DBC files.',
|
|
2027
|
+
)
|
|
2028
|
+
args = parser.parse_args()
|
|
2029
|
+
|
|
2030
|
+
# Set AppUserModelID so Windows taskbar shows the correct icon
|
|
2031
|
+
try:
|
|
2032
|
+
import ctypes
|
|
2033
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('toucan-plot')
|
|
2034
|
+
except Exception:
|
|
2035
|
+
pass
|
|
2036
|
+
qdarktheme.enable_hi_dpi()
|
|
2037
|
+
app = QtWidgets.QApplication(sys.argv)
|
|
2038
|
+
qdarktheme.setup_theme('dark')
|
|
2039
|
+
win = MainWindow()
|
|
2040
|
+
win.show()
|
|
2041
|
+
if args.files:
|
|
2042
|
+
win.open_files(args.files)
|
|
2043
|
+
sys.exit(app.exec())
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
if __name__ == '__main__':
|
|
2047
|
+
main()
|