qplotter 1.6.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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2082 @@
|
|
|
1
|
+
from math import ceil, floor, isclose, isfinite, log10, ulp
|
|
2
|
+
from os import path
|
|
3
|
+
from types import MethodType
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
import pyqtgraph as pg
|
|
7
|
+
from PyQt6 import QtCore, QtGui
|
|
8
|
+
from PyQt6 import QtWidgets as qtw
|
|
9
|
+
|
|
10
|
+
from qplot.tools import (
|
|
11
|
+
unpack_param,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from . import _plot_axis_scaling
|
|
15
|
+
from ._commands import (
|
|
16
|
+
command_spec,
|
|
17
|
+
command_with_status,
|
|
18
|
+
create_action,
|
|
19
|
+
toolbar_toggle_command_spec,
|
|
20
|
+
)
|
|
21
|
+
from ._config_persistence import persist_config_value
|
|
22
|
+
from ._dataset_handle import DatasetKey, TraceKey
|
|
23
|
+
from ._dragdrop import (
|
|
24
|
+
preview_drop_is_compatible,
|
|
25
|
+
run_preview_payload_from_mime,
|
|
26
|
+
)
|
|
27
|
+
from ._help import add_help_menu
|
|
28
|
+
from ._native_averaging import NativePlotItem, is_native_source_curve
|
|
29
|
+
from ._native_transforms import LINE_DISPLAY_RANGE_MESSAGE, native_fft_coordinate_error
|
|
30
|
+
from ._plot1d_snap import _line_snap_data
|
|
31
|
+
from ._plot_annotation import PlotAnnotationMixin
|
|
32
|
+
from ._plot_axis_scaling import (
|
|
33
|
+
PlotAxisScalingMixin,
|
|
34
|
+
_PowerScaledAxisItem,
|
|
35
|
+
)
|
|
36
|
+
from ._plot_export import PlotExportMixin
|
|
37
|
+
from ._plot_feedback import PlotWindowFeedbackMixin
|
|
38
|
+
from ._plot_marquee import PlotMarqueeMixin
|
|
39
|
+
from ._plot_refresh import PlotRefreshMixin
|
|
40
|
+
from ._plot_state import PlotStateOverlay
|
|
41
|
+
from ._plot_status import PlotStatusWidget
|
|
42
|
+
from ._plot_tiling import plot_tile_size
|
|
43
|
+
from ._plot_transform_labels import native_axis_quantities
|
|
44
|
+
from ._preferences import (
|
|
45
|
+
AXIS_MAJOR_TICK_COUNT_KEY,
|
|
46
|
+
CURSOR_SIGNIFICANT_FIGURES_KEY,
|
|
47
|
+
MOUSE_MODE_KEY,
|
|
48
|
+
PreferencesDialog,
|
|
49
|
+
create_preferences_action,
|
|
50
|
+
)
|
|
51
|
+
from ._refresh_interval import (
|
|
52
|
+
RefreshIntervalSpinBox,
|
|
53
|
+
connect_refresh_interval_edits,
|
|
54
|
+
refresh_interval_value,
|
|
55
|
+
remember_refresh_interval,
|
|
56
|
+
set_refresh_interval,
|
|
57
|
+
)
|
|
58
|
+
from ._subplots import custom_viewbox
|
|
59
|
+
from ._widgets import (
|
|
60
|
+
QDock_context,
|
|
61
|
+
expandingComboBox,
|
|
62
|
+
operations_widget,
|
|
63
|
+
)
|
|
64
|
+
from ._window_controls import (
|
|
65
|
+
add_application_quit_action,
|
|
66
|
+
add_standard_window_controls,
|
|
67
|
+
main_window_for,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if TYPE_CHECKING:
|
|
71
|
+
import qcodes
|
|
72
|
+
|
|
73
|
+
import qplot
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_axis_scale_power_text = _plot_axis_scaling._axis_scale_power_text
|
|
77
|
+
|
|
78
|
+
_MAX_TICK_POSITIONS = 2048
|
|
79
|
+
|
|
80
|
+
_A4_LANDSCAPE_PLOT_AREA_SIZE = QtCore.QSize(1123, 794)
|
|
81
|
+
_A4_PORTRAIT_PLOT_AREA_SIZE = QtCore.QSize(794, 1123)
|
|
82
|
+
_POWERPOINT_STANDARD_PLOT_AREA_SIZE = QtCore.QSize(960, 720)
|
|
83
|
+
_POWERPOINT_WIDESCREEN_PLOT_AREA_SIZE = QtCore.QSize(1280, 720)
|
|
84
|
+
_SQUARE_PLOT_AREA_SIZE = QtCore.QSize(850, 850)
|
|
85
|
+
_PLOT_AREA_RESIZE_PRESETS = (
|
|
86
|
+
(
|
|
87
|
+
"A4 Landscape",
|
|
88
|
+
"resizePlotAreaA4LandscapeAction",
|
|
89
|
+
_A4_LANDSCAPE_PLOT_AREA_SIZE,
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"A4 Portrait",
|
|
93
|
+
"resizePlotAreaA4PortraitAction",
|
|
94
|
+
_A4_PORTRAIT_PLOT_AREA_SIZE,
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"PowerPoint Standard",
|
|
98
|
+
"resizePlotAreaPowerPointStandardAction",
|
|
99
|
+
_POWERPOINT_STANDARD_PLOT_AREA_SIZE,
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"PowerPoint Widescreen",
|
|
103
|
+
"resizePlotAreaPowerPointWidescreenAction",
|
|
104
|
+
_POWERPOINT_WIDESCREEN_PLOT_AREA_SIZE,
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
"Square",
|
|
108
|
+
"resizePlotAreaSquareAction",
|
|
109
|
+
_SQUARE_PLOT_AREA_SIZE,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _tick_positions(min_val, max_val, spacing, offset):
|
|
115
|
+
"""Return the positions on one regular tick lattice inside a range."""
|
|
116
|
+
|
|
117
|
+
min_val, max_val = sorted((min_val, max_val))
|
|
118
|
+
if (
|
|
119
|
+
not all(isfinite(value) for value in (min_val, max_val, spacing, offset))
|
|
120
|
+
or spacing <= 0
|
|
121
|
+
):
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
data_range = max_val - min_val
|
|
125
|
+
span_tolerance = data_range * 1e-12 if isfinite(data_range) else 0.0
|
|
126
|
+
coordinate_precision = max(ulp(min_val), ulp(max_val))
|
|
127
|
+
if spacing < coordinate_precision:
|
|
128
|
+
return []
|
|
129
|
+
tolerance = max(
|
|
130
|
+
span_tolerance,
|
|
131
|
+
spacing * 1e-12,
|
|
132
|
+
coordinate_precision,
|
|
133
|
+
ulp(offset),
|
|
134
|
+
)
|
|
135
|
+
ratio_tolerance = tolerance / spacing
|
|
136
|
+
if not isfinite(ratio_tolerance):
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
if isfinite(data_range):
|
|
140
|
+
estimated_count = (data_range / spacing) + (2.0 * ratio_tolerance) + 2.0
|
|
141
|
+
else:
|
|
142
|
+
estimated_count = (
|
|
143
|
+
(max_val / spacing)
|
|
144
|
+
- (min_val / spacing)
|
|
145
|
+
+ (2.0 * ratio_tolerance)
|
|
146
|
+
+ 2.0
|
|
147
|
+
)
|
|
148
|
+
if not isfinite(estimated_count) or estimated_count > _MAX_TICK_POSITIONS:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
lower_ratio = (min_val - offset) / spacing
|
|
152
|
+
upper_ratio = (max_val - offset) / spacing
|
|
153
|
+
if not isfinite(lower_ratio) or not isfinite(upper_ratio):
|
|
154
|
+
return []
|
|
155
|
+
first_index = ceil(lower_ratio - ratio_tolerance)
|
|
156
|
+
last_index = floor(upper_ratio + ratio_tolerance)
|
|
157
|
+
count = last_index - first_index + 1
|
|
158
|
+
if count <= 0 or count > _MAX_TICK_POSITIONS:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
positions = []
|
|
162
|
+
for index in range(first_index, last_index + 1):
|
|
163
|
+
try:
|
|
164
|
+
position = (index * spacing) + offset
|
|
165
|
+
except OverflowError:
|
|
166
|
+
continue
|
|
167
|
+
if not isfinite(position):
|
|
168
|
+
continue
|
|
169
|
+
if position < min_val and min_val - position > tolerance:
|
|
170
|
+
continue
|
|
171
|
+
if position > max_val and position - max_val > tolerance:
|
|
172
|
+
continue
|
|
173
|
+
if not positions or position != positions[-1]:
|
|
174
|
+
positions.append(position)
|
|
175
|
+
return positions
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _visible_horizontal_tick_positions(axis, positions, min_val, max_val, size,
|
|
179
|
+
spacing):
|
|
180
|
+
"""Return ticks whose labels fit completely within a horizontal axis."""
|
|
181
|
+
|
|
182
|
+
if getattr(axis, "orientation", None) not in ("bottom", "top") or size <= 0:
|
|
183
|
+
return positions
|
|
184
|
+
|
|
185
|
+
scale = axis.autoSIPrefixScale * axis.scale
|
|
186
|
+
strings = _clean_tick_strings(axis, positions, scale, spacing)
|
|
187
|
+
font = axis.style.get("tickFont") or qtw.QApplication.font()
|
|
188
|
+
metrics = QtGui.QFontMetricsF(font)
|
|
189
|
+
data_range = max_val - min_val
|
|
190
|
+
visible = []
|
|
191
|
+
for position, string in zip(positions, strings, strict=True):
|
|
192
|
+
pixel = ((position - min_val) / data_range) * size
|
|
193
|
+
half_width = metrics.boundingRect(string).width() / 2.0
|
|
194
|
+
if half_width <= pixel <= size - half_width:
|
|
195
|
+
visible.append(position)
|
|
196
|
+
return visible
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _major_tick_spacing(axis, min_val, max_val, size):
|
|
200
|
+
"""Choose a zero-aligned 1-2-5 interval nearest the requested tick count."""
|
|
201
|
+
|
|
202
|
+
min_val, max_val = sorted((min_val, max_val))
|
|
203
|
+
if not isfinite(min_val) or not isfinite(max_val):
|
|
204
|
+
return []
|
|
205
|
+
data_range = max_val - min_val
|
|
206
|
+
if data_range == 0:
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
target = axis._qplot_major_tick_count
|
|
210
|
+
divisor = max(1, target - 1)
|
|
211
|
+
raw_spacing = (max_val / divisor) - (min_val / divisor)
|
|
212
|
+
if not isfinite(raw_spacing):
|
|
213
|
+
raw_spacing = max(abs(min_val), abs(max_val))
|
|
214
|
+
if raw_spacing == 0.0 and isfinite(data_range):
|
|
215
|
+
raw_spacing = data_range
|
|
216
|
+
if not isfinite(raw_spacing) or raw_spacing <= 0.0:
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
base_exponent = floor(log10(raw_spacing))
|
|
220
|
+
visible_candidates = []
|
|
221
|
+
fallback_candidates = []
|
|
222
|
+
for exponent in range(base_exponent - 2, base_exponent + 3):
|
|
223
|
+
try:
|
|
224
|
+
decade = 10.0 ** exponent
|
|
225
|
+
except OverflowError:
|
|
226
|
+
continue
|
|
227
|
+
if not isfinite(decade) or decade <= 0.0:
|
|
228
|
+
continue
|
|
229
|
+
for factor in (1.0, 2.0, 5.0):
|
|
230
|
+
spacing = factor * decade
|
|
231
|
+
if not isfinite(spacing) or spacing <= 0.0:
|
|
232
|
+
continue
|
|
233
|
+
for offset in (0.0,):
|
|
234
|
+
positions = _tick_positions(min_val, max_val, spacing, offset)
|
|
235
|
+
if not positions:
|
|
236
|
+
continue
|
|
237
|
+
fallback_candidates.append((
|
|
238
|
+
abs(len(positions) - target),
|
|
239
|
+
len(positions) < 2,
|
|
240
|
+
abs(log10(spacing) - log10(raw_spacing)),
|
|
241
|
+
spacing,
|
|
242
|
+
offset,
|
|
243
|
+
factor,
|
|
244
|
+
))
|
|
245
|
+
scored_positions = _visible_horizontal_tick_positions(
|
|
246
|
+
axis,
|
|
247
|
+
positions,
|
|
248
|
+
min_val,
|
|
249
|
+
max_val,
|
|
250
|
+
size,
|
|
251
|
+
spacing,
|
|
252
|
+
)
|
|
253
|
+
count = len(scored_positions)
|
|
254
|
+
if count == 0:
|
|
255
|
+
continue
|
|
256
|
+
if isfinite(data_range):
|
|
257
|
+
coverage = (
|
|
258
|
+
(scored_positions[-1] - scored_positions[0]) / data_range
|
|
259
|
+
if count > 1
|
|
260
|
+
else 0.0
|
|
261
|
+
)
|
|
262
|
+
centre_error = abs(
|
|
263
|
+
(
|
|
264
|
+
(scored_positions[0] - min_val)
|
|
265
|
+
+ (scored_positions[-1] - max_val)
|
|
266
|
+
) / 2.0
|
|
267
|
+
) / data_range
|
|
268
|
+
else:
|
|
269
|
+
scale = max(abs(min_val), abs(max_val))
|
|
270
|
+
scaled_range = (max_val / scale) - (min_val / scale)
|
|
271
|
+
coverage = (
|
|
272
|
+
(
|
|
273
|
+
(scored_positions[-1] / scale)
|
|
274
|
+
- (scored_positions[0] / scale)
|
|
275
|
+
) / scaled_range
|
|
276
|
+
if count > 1
|
|
277
|
+
else 0.0
|
|
278
|
+
)
|
|
279
|
+
tick_centre = (
|
|
280
|
+
(scored_positions[0] / scale) / 2.0
|
|
281
|
+
+ (scored_positions[-1] / scale) / 2.0
|
|
282
|
+
)
|
|
283
|
+
range_centre = (
|
|
284
|
+
(min_val / scale) / 2.0
|
|
285
|
+
+ (max_val / scale) / 2.0
|
|
286
|
+
)
|
|
287
|
+
centre_error = abs(tick_centre - range_centre) / scaled_range
|
|
288
|
+
visible_candidates.append((
|
|
289
|
+
abs(count - target),
|
|
290
|
+
count < 2,
|
|
291
|
+
-coverage,
|
|
292
|
+
centre_error,
|
|
293
|
+
abs(log10(spacing) - log10(raw_spacing)),
|
|
294
|
+
spacing,
|
|
295
|
+
offset,
|
|
296
|
+
factor,
|
|
297
|
+
))
|
|
298
|
+
|
|
299
|
+
if visible_candidates:
|
|
300
|
+
_, _, _, _, _, major_spacing, offset, factor = min(visible_candidates)
|
|
301
|
+
elif fallback_candidates:
|
|
302
|
+
_, _, _, major_spacing, offset, factor = min(fallback_candidates)
|
|
303
|
+
else:
|
|
304
|
+
major_spacing = raw_spacing
|
|
305
|
+
offset = 0.0
|
|
306
|
+
factor = 1.0
|
|
307
|
+
subdivisions = {
|
|
308
|
+
1.0: 5,
|
|
309
|
+
2.0: 4,
|
|
310
|
+
5.0: 5,
|
|
311
|
+
}[factor]
|
|
312
|
+
levels = [(major_spacing, offset)]
|
|
313
|
+
minor_spacing = major_spacing / subdivisions
|
|
314
|
+
if isfinite(minor_spacing) and 0.0 < minor_spacing < major_spacing:
|
|
315
|
+
levels.append((minor_spacing, offset))
|
|
316
|
+
return levels
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _clean_tick_strings(axis, values, scale, spacing):
|
|
320
|
+
"""Format nice intervals accurately and round-off near zero as zero."""
|
|
321
|
+
|
|
322
|
+
if axis.logMode and spacing is None:
|
|
323
|
+
return axis._qplot_original_tick_strings(
|
|
324
|
+
values,
|
|
325
|
+
scale,
|
|
326
|
+
spacing,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Work in tick coordinates: an absolute floor erases small measurements
|
|
330
|
+
# before SI scaling. Allow floating-point round-off, but never a meaningful
|
|
331
|
+
# fraction of a tick interval, even if distant ticks have coarse precision.
|
|
332
|
+
interval = abs(spacing)
|
|
333
|
+
precision = max((ulp(value) for value in values), default=ulp(interval))
|
|
334
|
+
tolerance = min(interval * 1e-6, max(interval * 1e-10, precision * 4))
|
|
335
|
+
clean_values = [0.0 if abs(value) <= tolerance else value for value in values]
|
|
336
|
+
if axis.logMode:
|
|
337
|
+
return axis._qplot_original_tick_strings(
|
|
338
|
+
clean_values,
|
|
339
|
+
scale,
|
|
340
|
+
spacing,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
scaled_spacing = abs(spacing * scale)
|
|
344
|
+
places = max(0, ceil(-log10(scaled_spacing)))
|
|
345
|
+
while places < 12 and not isclose(
|
|
346
|
+
scaled_spacing,
|
|
347
|
+
round(scaled_spacing, places),
|
|
348
|
+
rel_tol=1e-12,
|
|
349
|
+
abs_tol=10.0 ** (-places - 12),
|
|
350
|
+
):
|
|
351
|
+
places += 1
|
|
352
|
+
|
|
353
|
+
strings = []
|
|
354
|
+
for value in clean_values:
|
|
355
|
+
scaled_value = value * scale
|
|
356
|
+
if abs(scaled_value) < 0.001 or abs(scaled_value) >= 10_000:
|
|
357
|
+
strings.append(f"{scaled_value:g}")
|
|
358
|
+
else:
|
|
359
|
+
strings.append(f"{scaled_value:.{places}f}")
|
|
360
|
+
return strings
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _configure_major_ticks(axis, count):
|
|
364
|
+
"""Make an AxisItem use qPlot's sparse, range-aware major ticks."""
|
|
365
|
+
|
|
366
|
+
axis._qplot_major_tick_count = count
|
|
367
|
+
if not hasattr(axis, "_qplot_major_tick_spacing"):
|
|
368
|
+
axis._qplot_major_tick_spacing = axis.tickSpacing
|
|
369
|
+
axis.tickSpacing = MethodType(_major_tick_spacing, axis)
|
|
370
|
+
current_tick_strings = axis.tickStrings
|
|
371
|
+
if getattr(current_tick_strings, "__func__", None) is not _clean_tick_strings:
|
|
372
|
+
axis._qplot_original_tick_strings = current_tick_strings
|
|
373
|
+
axis.tickStrings = MethodType(_clean_tick_strings, axis)
|
|
374
|
+
axis.setStyle(maxTickLevel=1, maxTextLevel=0)
|
|
375
|
+
axis.picture = None
|
|
376
|
+
axis.update()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _plot_area_size_icon(size):
|
|
380
|
+
"""
|
|
381
|
+
Builds a small aspect-ratio preview icon for plot-area resize presets.
|
|
382
|
+
|
|
383
|
+
"""
|
|
384
|
+
pixmap = QtGui.QPixmap(28, 18)
|
|
385
|
+
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
|
|
386
|
+
|
|
387
|
+
bounds = QtCore.QSizeF(22, 14)
|
|
388
|
+
aspect = size.width() / size.height()
|
|
389
|
+
bounds_aspect = bounds.width() / bounds.height()
|
|
390
|
+
if aspect >= bounds_aspect:
|
|
391
|
+
width = bounds.width()
|
|
392
|
+
height = width / aspect
|
|
393
|
+
else:
|
|
394
|
+
height = bounds.height()
|
|
395
|
+
width = height * aspect
|
|
396
|
+
|
|
397
|
+
rect = QtCore.QRectF(
|
|
398
|
+
(pixmap.width() - width) / 2,
|
|
399
|
+
(pixmap.height() - height) / 2,
|
|
400
|
+
width,
|
|
401
|
+
height,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
painter = QtGui.QPainter(pixmap)
|
|
405
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
406
|
+
painter.setPen(QtGui.QPen(QtGui.QColor(70, 70, 70), 1))
|
|
407
|
+
painter.setBrush(QtGui.QColor(120, 150, 200, 70))
|
|
408
|
+
painter.drawRect(rect)
|
|
409
|
+
painter.end()
|
|
410
|
+
|
|
411
|
+
return QtGui.QIcon(pixmap)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class plotWidget(
|
|
415
|
+
PlotAnnotationMixin,
|
|
416
|
+
PlotWindowFeedbackMixin,
|
|
417
|
+
PlotAxisScalingMixin,
|
|
418
|
+
PlotMarqueeMixin,
|
|
419
|
+
PlotExportMixin,
|
|
420
|
+
PlotRefreshMixin,
|
|
421
|
+
qtw.QMainWindow,
|
|
422
|
+
):
|
|
423
|
+
"""
|
|
424
|
+
Base class for plot1d and plot2d.
|
|
425
|
+
Controls common setup and functions for both windows.
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
Refresh overview:
|
|
429
|
+
> Refresh monitor set at Main window time or 5s if none.
|
|
430
|
+
> On monitor timeout, calls self.refreshWindow() to check if refresh is needed
|
|
431
|
+
> Then produce worker for thread in self.load_data(). And queues to available
|
|
432
|
+
thread in self.threadPool.
|
|
433
|
+
See qplot.tools.worker.loader for more detail.
|
|
434
|
+
> Worker loads from SQL database inside worker and handles data to usable
|
|
435
|
+
form. See qplot.datahandling.LoadFromDB for more detail.
|
|
436
|
+
> On worker finish, worker callback to plot which calls self.refreshPlot().
|
|
437
|
+
plotWidget.refreshPlot() fetches data from worker, plot<1/2>d.refreshPlot()
|
|
438
|
+
then inherits, handles data, and renders as needed.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
closed = QtCore.pyqtSignal([object])
|
|
442
|
+
database_replaced = QtCore.pyqtSignal(str)
|
|
443
|
+
end_wait = QtCore.pyqtSignal()
|
|
444
|
+
make_ds = QtCore.pyqtSignal([object])
|
|
445
|
+
previewTraceDropRequested = QtCore.pyqtSignal(object, object, str)
|
|
446
|
+
trace_updated = QtCore.pyqtSignal()
|
|
447
|
+
|
|
448
|
+
def _view_range_changed_programmatically(self) -> None:
|
|
449
|
+
"""Allow plot types to react to qPlot-driven range changes."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
_label_width = 95 #About the size of 3 s.f. scientific
|
|
453
|
+
def __init__(self,
|
|
454
|
+
dataset_key: DatasetKey,
|
|
455
|
+
param : "qcodes.dataset.ParamSpec",
|
|
456
|
+
config : "qplot.configuration.config.config",
|
|
457
|
+
threadPool : "QtCore.QThreadPool",
|
|
458
|
+
dataset_holder : dict,
|
|
459
|
+
refrate : float=None,
|
|
460
|
+
show : bool=True
|
|
461
|
+
):
|
|
462
|
+
"""
|
|
463
|
+
Initialises window and sets up all required widgets. Also calls functions
|
|
464
|
+
for static plotting and checks for live plotting.
|
|
465
|
+
|
|
466
|
+
Parameters
|
|
467
|
+
----------
|
|
468
|
+
dataset_key : DatasetKey
|
|
469
|
+
The database-aware identity of the dataset to plot.
|
|
470
|
+
param : qcodes.dataset.ParamSpec
|
|
471
|
+
Which parameter within dataset to plot.
|
|
472
|
+
config : qplot.configuration.config.config
|
|
473
|
+
Holds configuration data, mainly theme and window size.
|
|
474
|
+
threadPool : PyQt6.QtCore.QThreadPool
|
|
475
|
+
A pool of threads for the refresh worker to be placed in.
|
|
476
|
+
dataset_holder : dict[DatasetKey, DatasetHandle]
|
|
477
|
+
Shared map of dataset identities to open-dataset ownership handles.
|
|
478
|
+
refrate : float, optional
|
|
479
|
+
Default value for the refresh timer. The default is None, which
|
|
480
|
+
corresponds to a 5.0s refresh time.
|
|
481
|
+
show : bool, optional
|
|
482
|
+
Whether to display the window or not. The default is True.
|
|
483
|
+
When false reduces produced widgets to reduce workload.
|
|
484
|
+
|
|
485
|
+
"""
|
|
486
|
+
super().__init__()
|
|
487
|
+
|
|
488
|
+
### CORE VARIABLES
|
|
489
|
+
self._dataset_holder = dataset_holder
|
|
490
|
+
self._dataset_key = dataset_key
|
|
491
|
+
self._guid = dataset_key.guid
|
|
492
|
+
self.param = param
|
|
493
|
+
self._plot_source_info = (
|
|
494
|
+
dataset_key.database_path, self.ds.run_id, param.label or param.name,
|
|
495
|
+
)
|
|
496
|
+
self._qplot_display_synchronized = False
|
|
497
|
+
self._qplot_display_uses_direct_sql = False
|
|
498
|
+
self.name = str(self)
|
|
499
|
+
self.label = f"ID:{self.ds.run_id} {self.param.name}"
|
|
500
|
+
self._trace_key = TraceKey(self._dataset_key, self.param.name)
|
|
501
|
+
self.monitor = QtCore.QTimer()
|
|
502
|
+
self.threadPool = threadPool
|
|
503
|
+
self.last_ds_len = self.ds.number_of_results
|
|
504
|
+
self.config = config
|
|
505
|
+
self.visible = show
|
|
506
|
+
self._closed = False
|
|
507
|
+
self._merged_trace_users = 0
|
|
508
|
+
self.operations = {}
|
|
509
|
+
self._last_error_text = None
|
|
510
|
+
|
|
511
|
+
### WIDGETS
|
|
512
|
+
self._window_layout = qtw.QVBoxLayout()
|
|
513
|
+
# The graphics widget supplies its own canvas background. Avoid
|
|
514
|
+
# exposing the window background as an unintended surround around it.
|
|
515
|
+
self._window_layout.setContentsMargins(0, 0, 0, 0)
|
|
516
|
+
|
|
517
|
+
self.widget = pg.GraphicsLayoutWidget()
|
|
518
|
+
self._install_preview_drop_target()
|
|
519
|
+
# Overwrite default viewbox to give more flexibility
|
|
520
|
+
self.vb = custom_viewbox() # Mainly for linking secondary axis
|
|
521
|
+
self.vb.setDefaultPadding(0)
|
|
522
|
+
self.apply_mouse_mode_preference()
|
|
523
|
+
self.plot = NativePlotItem(
|
|
524
|
+
viewBox=self.vb,
|
|
525
|
+
axisItems={
|
|
526
|
+
"bottom": _PowerScaledAxisItem("bottom"),
|
|
527
|
+
"left": _PowerScaledAxisItem("left"),
|
|
528
|
+
"top": _PowerScaledAxisItem("top"),
|
|
529
|
+
"right": _PowerScaledAxisItem("right"),
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
self.widget.addItem(self.plot)
|
|
533
|
+
self.apply_axis_major_tick_count_preference()
|
|
534
|
+
self.vb.setParent(self.plot)
|
|
535
|
+
self.vb.set_marquee_owner(self)
|
|
536
|
+
self._init_marquee()
|
|
537
|
+
self._window_layout.addWidget(self.widget)
|
|
538
|
+
|
|
539
|
+
### CORE INIT FUNCTIONS
|
|
540
|
+
self.initAxes()
|
|
541
|
+
self.initOperations()
|
|
542
|
+
self.initRefresh(refrate)
|
|
543
|
+
self.plot_state_overlay = PlotStateOverlay(self.widget, self.plot_status)
|
|
544
|
+
self.show_status("Working, please wait", 0)
|
|
545
|
+
self.initFrame() # See plot1d, plot2d
|
|
546
|
+
self._init_plot_annotation()
|
|
547
|
+
self._init_native_transform_labels()
|
|
548
|
+
|
|
549
|
+
if self.visible: #dont run non essential GUI functions if not displaying
|
|
550
|
+
self.initLabels()
|
|
551
|
+
self.initContextMenu()
|
|
552
|
+
self.initMenu()
|
|
553
|
+
|
|
554
|
+
### FORMATING
|
|
555
|
+
self.setWindowTitle(str(self))
|
|
556
|
+
|
|
557
|
+
self.plot.showAxis("right")
|
|
558
|
+
self.plot.showAxis("top")
|
|
559
|
+
|
|
560
|
+
self.plot.getAxis('top').setStyle(showValues=False)
|
|
561
|
+
self.plot.getAxis('right').setStyle(showValues=False)
|
|
562
|
+
|
|
563
|
+
screenrect = qtw.QApplication.primaryScreen().availableGeometry()
|
|
564
|
+
initial_width, initial_height = plot_tile_size(self.config, screenrect)
|
|
565
|
+
self.resize(initial_width, initial_height)
|
|
566
|
+
|
|
567
|
+
# Hidden source plots also own their canvas and progress timer. Their
|
|
568
|
+
# toolbar must not be destroyed while an unparented canvas keeps polling.
|
|
569
|
+
w = qtw.QFrame()
|
|
570
|
+
w.setLayout(self._window_layout)
|
|
571
|
+
self.setCentralWidget(w)
|
|
572
|
+
|
|
573
|
+
# Keep the timer alive through this plot's own terminal display commit,
|
|
574
|
+
# even if another parameter publishes shared dataset completion first.
|
|
575
|
+
if self._refresh_monitor_required(self.ds):
|
|
576
|
+
self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def event(self, event):
|
|
580
|
+
# Menu hover tips must not create a bottom status bar or overwrite the
|
|
581
|
+
# persistent operation message in the refresh toolbar.
|
|
582
|
+
if event.type() == QtCore.QEvent.Type.StatusTip:
|
|
583
|
+
return True
|
|
584
|
+
return super().event(event)
|
|
585
|
+
|
|
586
|
+
def _install_preview_drop_target(self):
|
|
587
|
+
"""Receive preview drops at the window, outside pyqtgraph's view.
|
|
588
|
+
|
|
589
|
+
``GraphicsLayoutWidget`` is a ``QGraphicsView``. Consuming its
|
|
590
|
+
drag-enter event in an event filter prevents the view from recording
|
|
591
|
+
the enter, but it can still receive the matching drag-leave. Besides
|
|
592
|
+
producing Qt's "drag leave received before drag enter" warning, that
|
|
593
|
+
leaves the view's drag state inconsistent. Leave the graphics view
|
|
594
|
+
out of this event sequence and let Qt deliver the drag to this
|
|
595
|
+
accepting parent window instead.
|
|
596
|
+
"""
|
|
597
|
+
self.setAcceptDrops(True)
|
|
598
|
+
|
|
599
|
+
viewport = self.widget.viewport() if hasattr(self.widget, "viewport") else None
|
|
600
|
+
self.widget.setAcceptDrops(False)
|
|
601
|
+
if viewport is not None:
|
|
602
|
+
viewport.setAcceptDrops(False)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _set_param_axis_label(self, axis, param):
|
|
606
|
+
self.plot.setLabel(axis=axis, text=param.label, units=param.unit)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _set_param_axis_labels(self):
|
|
610
|
+
parameters = self.axis_param
|
|
611
|
+
line = self.__dict__.get("line")
|
|
612
|
+
if line is not None:
|
|
613
|
+
parameters = native_axis_quantities(
|
|
614
|
+
parameters["x"], parameters["y"], line.opts,
|
|
615
|
+
)
|
|
616
|
+
self._set_param_axis_label("bottom", parameters["x"])
|
|
617
|
+
self._set_param_axis_label("left", parameters["y"])
|
|
618
|
+
|
|
619
|
+
def _init_native_transform_labels(self):
|
|
620
|
+
"""Coordinate line transforms before any native curve update runs."""
|
|
621
|
+
native_slots = {
|
|
622
|
+
"fftCheck": self.plot.updateSpectrumMode,
|
|
623
|
+
"derivativeCheck": self.plot.updateDerivativeMode,
|
|
624
|
+
"phasemapCheck": self.plot.updatePhasemapMode,
|
|
625
|
+
"subtractMeanCheck": self.plot.updateSubtractMeanMode,
|
|
626
|
+
}
|
|
627
|
+
self._qplot_native_transform_update = False
|
|
628
|
+
for name, slot in native_slots.items():
|
|
629
|
+
control = getattr(self.plot.ctrl, name)
|
|
630
|
+
if self.__dict__.get("line") is None:
|
|
631
|
+
control.toggled.connect(self._native_transform_labels_changed)
|
|
632
|
+
continue
|
|
633
|
+
# PlotItem connected these first. A later label/validation slot is
|
|
634
|
+
# too late: its native setters synchronously rebuild curve data.
|
|
635
|
+
control.toggled.disconnect(slot)
|
|
636
|
+
control.toggled.connect(
|
|
637
|
+
lambda value, name=name: self._native_transform_controls_changed(name, value)
|
|
638
|
+
)
|
|
639
|
+
self._install_native_transform_trace_handler(self.__dict__.get("line"))
|
|
640
|
+
|
|
641
|
+
def _native_transform_controls_changed(self, name, _value):
|
|
642
|
+
if self._qplot_native_transform_update:
|
|
643
|
+
return
|
|
644
|
+
self._qplot_native_transform_update = True
|
|
645
|
+
error = None
|
|
646
|
+
try:
|
|
647
|
+
ctrl = self.plot.ctrl
|
|
648
|
+
# Validate every curve before any synchronous native setter runs.
|
|
649
|
+
# A rejected FFT must also retain an outgoing derivative mode.
|
|
650
|
+
wants_fft = (ctrl.fftCheck.isChecked() and not ctrl.phasemapCheck.isChecked()
|
|
651
|
+
and (not ctrl.derivativeCheck.isChecked() or name == "fftCheck"))
|
|
652
|
+
if wants_fft:
|
|
653
|
+
error = self._native_fft_validation_error()
|
|
654
|
+
if error:
|
|
655
|
+
ctrl.fftCheck.setChecked(False)
|
|
656
|
+
if (not ctrl.phasemapCheck.isChecked()
|
|
657
|
+
and ctrl.fftCheck.isChecked() and ctrl.derivativeCheck.isChecked()):
|
|
658
|
+
# Outside phase map, the newly enabled transform wins. Leaving
|
|
659
|
+
# phase map retains derivative, whose input samples it used.
|
|
660
|
+
incompatible = ctrl.derivativeCheck if name == "fftCheck" else ctrl.fftCheck
|
|
661
|
+
incompatible.setChecked(False)
|
|
662
|
+
for line in tuple(self.plot.items):
|
|
663
|
+
if is_native_source_curve(line):
|
|
664
|
+
self._sync_native_trace_transforms(line)
|
|
665
|
+
self.plot.enableAutoRange()
|
|
666
|
+
self.plot.recomputeAverages()
|
|
667
|
+
finally:
|
|
668
|
+
self._qplot_native_transform_update = False
|
|
669
|
+
self._native_transform_labels_changed()
|
|
670
|
+
if error:
|
|
671
|
+
self._qplot_native_fft_error_text = error
|
|
672
|
+
self.show_status(error, 0)
|
|
673
|
+
elif self.__dict__.get("_qplot_native_fft_error_text"):
|
|
674
|
+
self._qplot_native_fft_error_text = None
|
|
675
|
+
self.show_status("", 0)
|
|
676
|
+
self._update_native_precision_status()
|
|
677
|
+
|
|
678
|
+
def _update_native_precision_status(self):
|
|
679
|
+
error = next((line._qplot_precision_error for line in tuple(self.plot.items)
|
|
680
|
+
if is_native_source_curve(line) and line.isVisible()
|
|
681
|
+
and getattr(line, "_qplot_precision_error", None)), None)
|
|
682
|
+
if error:
|
|
683
|
+
self._qplot_native_precision_error_text = error
|
|
684
|
+
title = ("Unsupported line coordinate range" if error == LINE_DISPLAY_RANGE_MESSAGE
|
|
685
|
+
else "Cannot display recorded distinctions")
|
|
686
|
+
self.show_plot_state(title, error, kind="error")
|
|
687
|
+
if self.__dict__.get("_qplot_native_fft_error_text"):
|
|
688
|
+
self.show_status(self._qplot_native_fft_error_text, 0)
|
|
689
|
+
elif self.__dict__.get("_qplot_native_precision_error_text"):
|
|
690
|
+
previous = self._qplot_native_precision_error_text
|
|
691
|
+
self._qplot_native_precision_error_text = None
|
|
692
|
+
overlay = self.plot_state_overlay
|
|
693
|
+
if (overlay.title_label.text() in {"Cannot display recorded distinctions",
|
|
694
|
+
"Unsupported line coordinate range"}
|
|
695
|
+
and overlay.detail_label.text() == previous):
|
|
696
|
+
overlay.hide()
|
|
697
|
+
if not self.__dict__.get("_qplot_native_fft_error_text"):
|
|
698
|
+
self.show_status("", 0)
|
|
699
|
+
|
|
700
|
+
def _native_fft_validation_error(self):
|
|
701
|
+
for line in tuple(self.plot.items):
|
|
702
|
+
if is_native_source_curve(line):
|
|
703
|
+
error = native_fft_coordinate_error(line.getOriginalDataset()[0])
|
|
704
|
+
if error:
|
|
705
|
+
return error
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
def _native_transform_labels_changed(self, _value=None):
|
|
709
|
+
if self._qplot_native_transform_update:
|
|
710
|
+
return
|
|
711
|
+
ctrl = self.plot.ctrl
|
|
712
|
+
if (ctrl.fftCheck.isChecked() and not ctrl.phasemapCheck.isChecked()
|
|
713
|
+
and self._native_fft_validation_error()):
|
|
714
|
+
# setData has a safe empty FFT fallback. Its publication signal
|
|
715
|
+
# now restores all curves, controls and labels to non-FFT mode.
|
|
716
|
+
self._native_transform_controls_changed("fftCheck", True)
|
|
717
|
+
return
|
|
718
|
+
if {"x", "y"}.issubset(self.__dict__.get("axis_param", {})):
|
|
719
|
+
self._set_param_axis_labels()
|
|
720
|
+
if (self.__dict__.get("_qplot_native_fft_error_text")
|
|
721
|
+
and self._native_fft_validation_error()):
|
|
722
|
+
# A worker's load-status message may follow the initial rollback
|
|
723
|
+
# during an axis change. Keep its FFT explanation after publication.
|
|
724
|
+
self.show_status(self._qplot_native_fft_error_text, 0)
|
|
725
|
+
self._update_native_precision_status()
|
|
726
|
+
|
|
727
|
+
def _install_native_transform_trace_handler(self, line):
|
|
728
|
+
if line is None or getattr(line, "_qplot_transform_label_handler", False):
|
|
729
|
+
return
|
|
730
|
+
line.sigPlotChanged.connect(self._native_transform_labels_changed)
|
|
731
|
+
line.sigDisplayChanged.connect(self._update_native_precision_status)
|
|
732
|
+
line._qplot_transform_label_handler = True
|
|
733
|
+
|
|
734
|
+
def _sync_native_trace_transforms(self, line):
|
|
735
|
+
"""Apply validated controls through valid intermediate curve states.
|
|
736
|
+
|
|
737
|
+
Native setters update immediately. Clear outgoing FFT/derivative modes
|
|
738
|
+
first, enable phase map before combining them, and disable phase map
|
|
739
|
+
last. This also covers new traces and traces on secondary viewboxes.
|
|
740
|
+
"""
|
|
741
|
+
if not is_native_source_curve(line):
|
|
742
|
+
return
|
|
743
|
+
ctrl = self.plot.ctrl
|
|
744
|
+
if (not self._qplot_native_transform_update and ctrl.fftCheck.isChecked()
|
|
745
|
+
and not ctrl.phasemapCheck.isChecked() and self._native_fft_validation_error()):
|
|
746
|
+
# New traces inherit PlotItem's controls during addItem. Include
|
|
747
|
+
# them in a window-wide rollback before syncing individual modes.
|
|
748
|
+
self._native_transform_controls_changed("fftCheck", True)
|
|
749
|
+
return
|
|
750
|
+
fft = ctrl.fftCheck.isChecked()
|
|
751
|
+
derivative = ctrl.derivativeCheck.isChecked()
|
|
752
|
+
phase = ctrl.phasemapCheck.isChecked()
|
|
753
|
+
if not fft:
|
|
754
|
+
line.setFftMode(False)
|
|
755
|
+
if not derivative:
|
|
756
|
+
line.setDerivativeMode(False)
|
|
757
|
+
if phase:
|
|
758
|
+
line.setPhasemapMode(True)
|
|
759
|
+
line.setSubtractMeanMode(ctrl.subtractMeanCheck.isChecked())
|
|
760
|
+
line.setFftMode(fft)
|
|
761
|
+
line.setDerivativeMode(derivative)
|
|
762
|
+
if not phase:
|
|
763
|
+
line.setPhasemapMode(False)
|
|
764
|
+
self._install_native_transform_trace_handler(line)
|
|
765
|
+
if not self._qplot_native_transform_update:
|
|
766
|
+
self.plot.recomputeAverages()
|
|
767
|
+
|
|
768
|
+
def dragEnterEvent(self, event):
|
|
769
|
+
if self._handle_preview_drag_drop(event):
|
|
770
|
+
return
|
|
771
|
+
super().dragEnterEvent(event)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def dragMoveEvent(self, event):
|
|
775
|
+
if self._handle_preview_drag_drop(event):
|
|
776
|
+
return
|
|
777
|
+
super().dragMoveEvent(event)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def dropEvent(self, event):
|
|
781
|
+
if self._handle_preview_drag_drop(event):
|
|
782
|
+
return
|
|
783
|
+
super().dropEvent(event)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _handle_preview_drag_drop(self, event):
|
|
787
|
+
payload = run_preview_payload_from_mime(event.mimeData())
|
|
788
|
+
if payload is None:
|
|
789
|
+
return False
|
|
790
|
+
|
|
791
|
+
if not self.accepts_preview_trace_drop(payload):
|
|
792
|
+
if event.type() == QtCore.QEvent.Type.Drop:
|
|
793
|
+
self.show_status(self._preview_drop_rejection_message(payload), 5000)
|
|
794
|
+
event.ignore()
|
|
795
|
+
return True
|
|
796
|
+
|
|
797
|
+
if event.type() == QtCore.QEvent.Type.Drop:
|
|
798
|
+
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
|
|
799
|
+
event.accept()
|
|
800
|
+
source_identity = payload["guid"]
|
|
801
|
+
if payload.get("database_path"):
|
|
802
|
+
source_identity = DatasetKey(
|
|
803
|
+
payload["database_path"],
|
|
804
|
+
payload["guid"],
|
|
805
|
+
)
|
|
806
|
+
self.previewTraceDropRequested.emit(
|
|
807
|
+
self,
|
|
808
|
+
source_identity,
|
|
809
|
+
payload["parameter"]
|
|
810
|
+
)
|
|
811
|
+
return True
|
|
812
|
+
|
|
813
|
+
event.acceptProposedAction()
|
|
814
|
+
return True
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def accepts_preview_trace_drop(self, payload):
|
|
818
|
+
operation_kind = getattr(self, "operation_kind", None)
|
|
819
|
+
if operation_kind not in {"plot1d", "plot2d"}:
|
|
820
|
+
return False
|
|
821
|
+
|
|
822
|
+
target_axes = tuple(getattr(self.param, "depends_on_", ()))
|
|
823
|
+
return preview_drop_is_compatible(target_axes, payload)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _preview_drop_rejection_message(self, payload):
|
|
827
|
+
"""Explain why a preview cannot be added to this plot."""
|
|
828
|
+
|
|
829
|
+
parameter_name = str(payload.get("parameter") or "this measurement")
|
|
830
|
+
operation_kind = getattr(self, "operation_kind", None)
|
|
831
|
+
if operation_kind == "plot1d":
|
|
832
|
+
return (
|
|
833
|
+
f"Cannot add {parameter_name}; plots support 1D or 2D measurements."
|
|
834
|
+
)
|
|
835
|
+
if operation_kind == "plot2d":
|
|
836
|
+
return (
|
|
837
|
+
f"Cannot add {parameter_name}; plots support 1D or 2D measurements."
|
|
838
|
+
)
|
|
839
|
+
return "Drop previews onto a compatible line plot or heatmap."
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def __str__(self):
|
|
844
|
+
filenameStr = path.basename(self._dataset_key.database_path)
|
|
845
|
+
fstr = (f"{filenameStr} | "
|
|
846
|
+
f"Run ID: {self.ds.run_id} | "
|
|
847
|
+
f"{self.param.name} ({self.param.label})"
|
|
848
|
+
)
|
|
849
|
+
return fstr
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@property
|
|
853
|
+
def ds(self):
|
|
854
|
+
"""
|
|
855
|
+
Returns the window's dataset from the dictionary of stored datasets
|
|
856
|
+
|
|
857
|
+
Returns
|
|
858
|
+
-------
|
|
859
|
+
qcodes.dataset.data_set.dataset
|
|
860
|
+
|
|
861
|
+
"""
|
|
862
|
+
# Check dataset exists, produce new one if needed.
|
|
863
|
+
handle = self._dataset_holder.get(self._dataset_key)
|
|
864
|
+
if handle is None:
|
|
865
|
+
self.show_status(f"Dataset {self._guid} not found. Reloading...", 5000)
|
|
866
|
+
self.make_ds.emit(self._dataset_key)
|
|
867
|
+
handle = self._dataset_holder[self._dataset_key]
|
|
868
|
+
|
|
869
|
+
# Check a deletion timer is not active and stop
|
|
870
|
+
else:
|
|
871
|
+
handle.cancel_delete_timer()
|
|
872
|
+
|
|
873
|
+
return handle.dataset
|
|
874
|
+
|
|
875
|
+
###############################################################################
|
|
876
|
+
# Init functions
|
|
877
|
+
|
|
878
|
+
def initRefresh(self, refrate : float):
|
|
879
|
+
"""
|
|
880
|
+
Sets up refresh logic and widgets. Along with top toolbar
|
|
881
|
+
|
|
882
|
+
Parameters
|
|
883
|
+
----------
|
|
884
|
+
refrate : float
|
|
885
|
+
Default value for the refresh timer.
|
|
886
|
+
|
|
887
|
+
"""
|
|
888
|
+
self.toolbarRef = qtw.QToolBar("Refresh and status")
|
|
889
|
+
self.toolbarRef.setObjectName("plotRefreshToolbar")
|
|
890
|
+
self.addToolBar(QtCore.Qt.ToolBarArea.TopToolBarArea, self.toolbarRef)
|
|
891
|
+
|
|
892
|
+
self.spinBox = RefreshIntervalSpinBox()
|
|
893
|
+
self.spinBox.setObjectName("refreshIntervalSpin")
|
|
894
|
+
self.spinBox.setRange(0.0, 10_000.0)
|
|
895
|
+
self.spinBox.setSingleStep(0.1)
|
|
896
|
+
self.spinBox.setDecimals(1)
|
|
897
|
+
|
|
898
|
+
self.refreshPlotButton = qtw.QToolButton()
|
|
899
|
+
self.refreshPlotButton.setObjectName("refreshPlotButton")
|
|
900
|
+
self.refreshPlotButton.setIcon(
|
|
901
|
+
self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_BrowserReload)
|
|
902
|
+
)
|
|
903
|
+
self.refreshPlotButton.setToolTip("Refresh plot data now (R)")
|
|
904
|
+
self.refreshPlotButton.setAccessibleName("Refresh plot data")
|
|
905
|
+
self.refreshPlotButton.setFixedSize(28, 26)
|
|
906
|
+
self.refreshPlotButton.clicked.connect(lambda: self.refreshWindow(force=True))
|
|
907
|
+
self.toolbarRef.addWidget(self.refreshPlotButton)
|
|
908
|
+
self.toolbarRef.addWidget(qtw.QLabel("Refresh interval (s): "))
|
|
909
|
+
self.toolbarRef.addWidget(self.spinBox)
|
|
910
|
+
self.plot_status = PlotStatusWidget(self.toolbarRef)
|
|
911
|
+
self.plotStatusAction = self.toolbarRef.addWidget(self.plot_status)
|
|
912
|
+
|
|
913
|
+
if refrate is not None and refrate > 0:
|
|
914
|
+
set_refresh_interval(self.spinBox, refrate)
|
|
915
|
+
else:
|
|
916
|
+
set_refresh_interval(
|
|
917
|
+
self.spinBox, self.config.get("user_preference.default_refresh_rate"),
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
connect_refresh_interval_edits(self.spinBox, self.monitorIntervalChanged)
|
|
921
|
+
self.monitor.timeout.connect(self.refreshWindow)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def initLabels(self):
|
|
925
|
+
"""
|
|
926
|
+
Sets up bottom toolbar which displays cursor point.
|
|
927
|
+
|
|
928
|
+
"""
|
|
929
|
+
self.toolbarCo_ord = qtw.QToolBar("Coordinates")
|
|
930
|
+
self.addToolBar(QtCore.Qt.ToolBarArea.BottomToolBarArea, self.toolbarCo_ord)
|
|
931
|
+
|
|
932
|
+
self.pos_labels = {}
|
|
933
|
+
|
|
934
|
+
posLabelIndex = qtw.QLabel("")
|
|
935
|
+
self.toolbarCo_ord.addWidget(posLabelIndex)
|
|
936
|
+
self.pos_labels["index"] = posLabelIndex
|
|
937
|
+
|
|
938
|
+
posLabelx = qtw.QLabel("x= ")
|
|
939
|
+
self.toolbarCo_ord.addWidget(posLabelx)
|
|
940
|
+
self.pos_labels["x"] = posLabelx
|
|
941
|
+
|
|
942
|
+
posLabely = qtw.QLabel("y= ")
|
|
943
|
+
self.toolbarCo_ord.addWidget(posLabely)
|
|
944
|
+
self.pos_labels["y"] = posLabely
|
|
945
|
+
|
|
946
|
+
self.toolbarCo_ord.addWidget(qtw.QLabel(" "))
|
|
947
|
+
self.apply_cursor_label_widths()
|
|
948
|
+
|
|
949
|
+
self.plot.scene().sigMouseMoved.connect(self.mouseMoved)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def initContextMenu(self):
|
|
953
|
+
"""
|
|
954
|
+
Adjusts the default plot context menu.
|
|
955
|
+
|
|
956
|
+
"""
|
|
957
|
+
self.vbMenu = self.vb.menu
|
|
958
|
+
self.mouseModeAction = self._context_menu_action("Mouse Mode")
|
|
959
|
+
self._connect_mouse_mode_menu_to_preferences()
|
|
960
|
+
self._remove_scene_export_context_menu()
|
|
961
|
+
if getattr(self.plot, "ctrlMenu", None) is not None:
|
|
962
|
+
self.plot.ctrlMenu.setTitle("Options")
|
|
963
|
+
self.plot.ctrlMenu.menuAction().setText("Options")
|
|
964
|
+
|
|
965
|
+
self.exportPlotAction = create_action("plot.export", self)
|
|
966
|
+
self.register_shortcut(
|
|
967
|
+
self.exportPlotAction,
|
|
968
|
+
command_spec("plot.export"),
|
|
969
|
+
)
|
|
970
|
+
self.exportPlotAction.triggered.connect(self.open_export_dialog)
|
|
971
|
+
|
|
972
|
+
self.savePlotPdfAction = QtGui.QAction("Save Plot as &PDF...", self)
|
|
973
|
+
self.savePlotPdfAction.setObjectName("savePlotPdfAction")
|
|
974
|
+
self.savePlotPdfAction.setStatusTip("Save the visible plot area as a PDF")
|
|
975
|
+
self.savePlotPdfAction.triggered.connect(self.save_plot_pdf)
|
|
976
|
+
|
|
977
|
+
self.printPlotAction = create_action("plot.print", self)
|
|
978
|
+
self.register_shortcut(
|
|
979
|
+
self.printPlotAction,
|
|
980
|
+
command_spec("plot.print"),
|
|
981
|
+
)
|
|
982
|
+
self.printPlotAction.triggered.connect(self.print_plot)
|
|
983
|
+
|
|
984
|
+
self.copyPlotImageAction = create_action("plot.copy_image", self)
|
|
985
|
+
self.register_shortcut(
|
|
986
|
+
self.copyPlotImageAction,
|
|
987
|
+
command_spec("plot.copy_image"),
|
|
988
|
+
)
|
|
989
|
+
self.copyPlotImageAction.triggered.connect(self.copy_plot_image)
|
|
990
|
+
|
|
991
|
+
contextAction = create_action(
|
|
992
|
+
"context.show",
|
|
993
|
+
self,
|
|
994
|
+
status_tip="Show plot context menu",
|
|
995
|
+
)
|
|
996
|
+
self.register_shortcut(
|
|
997
|
+
contextAction,
|
|
998
|
+
command_with_status("context.show", "Show plot context menu"),
|
|
999
|
+
)
|
|
1000
|
+
contextAction.triggered.connect(self.open_context_menu)
|
|
1001
|
+
|
|
1002
|
+
actions = self.vbMenu.actions()
|
|
1003
|
+
for action in actions:
|
|
1004
|
+
if action.text() == "View All":
|
|
1005
|
+
self.register_shortcut(action, command_spec("plot.autoscale"))
|
|
1006
|
+
action.setText(command_spec("plot.autoscale").text)
|
|
1007
|
+
# Line owners install a validated full-view handler before
|
|
1008
|
+
# native autoRange mutates the range. Their context callback
|
|
1009
|
+
# already applies this guard once; heatmaps retain this hook.
|
|
1010
|
+
if getattr(self, "operation_kind", None) not in ("plot1d", "sweeper"):
|
|
1011
|
+
action.triggered.connect(
|
|
1012
|
+
lambda _checked=False: self.force_all_axes_autoscale()
|
|
1013
|
+
)
|
|
1014
|
+
break
|
|
1015
|
+
|
|
1016
|
+
x_action = actions[1]
|
|
1017
|
+
|
|
1018
|
+
self.autoscaleSep = self.vbMenu.insertSeparator(x_action)
|
|
1019
|
+
|
|
1020
|
+
self.vbMenu.insertAction(x_action, self.savePlotPdfAction)
|
|
1021
|
+
self.vbMenu.insertSeparator(x_action)
|
|
1022
|
+
self.vbMenu.insertAction(x_action, self.copyPlotImageAction)
|
|
1023
|
+
self.vbMenu.insertSeparator(x_action)
|
|
1024
|
+
|
|
1025
|
+
# Create visibility
|
|
1026
|
+
toggleAction = create_action(
|
|
1027
|
+
"plot.toggle_operations",
|
|
1028
|
+
self,
|
|
1029
|
+
checkable=True,
|
|
1030
|
+
)
|
|
1031
|
+
self.register_shortcut(toggleAction, command_spec("plot.toggle_operations"))
|
|
1032
|
+
toggleAction.triggered.connect(self.oper_dock.setVisible)
|
|
1033
|
+
self.oper_dock.visibilityChanged.connect(toggleAction.setChecked)
|
|
1034
|
+
self.vbMenu.insertAction(x_action, toggleAction)
|
|
1035
|
+
self.vbMenu.insertSeparator(x_action)
|
|
1036
|
+
|
|
1037
|
+
self._init_axis_scale_dialogs()
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _remove_scene_export_context_menu(self):
|
|
1041
|
+
"""
|
|
1042
|
+
Removes every scene-level generic action and owns its exporter cache.
|
|
1043
|
+
|
|
1044
|
+
"""
|
|
1045
|
+
scene = self.widget.scene()
|
|
1046
|
+
# PyQtGraph's default list currently contains only its direct Export
|
|
1047
|
+
# action. Clearing the list by identity-independent ownership is safer
|
|
1048
|
+
# than matching translated display text.
|
|
1049
|
+
scene.contextMenu = []
|
|
1050
|
+
install_dialog = getattr(
|
|
1051
|
+
self,
|
|
1052
|
+
"_install_safe_pyqtgraph_export_dialog",
|
|
1053
|
+
None,
|
|
1054
|
+
)
|
|
1055
|
+
if callable(install_dialog):
|
|
1056
|
+
install_dialog()
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _context_menu_action(self, text):
|
|
1060
|
+
"""
|
|
1061
|
+
Returns a pyqtgraph context-menu action by display text.
|
|
1062
|
+
|
|
1063
|
+
"""
|
|
1064
|
+
for action in self.vbMenu.actions():
|
|
1065
|
+
if action.text().replace("&", "") == text:
|
|
1066
|
+
return action
|
|
1067
|
+
return None
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
@QtCore.pyqtSlot()
|
|
1071
|
+
def open_context_menu(self):
|
|
1072
|
+
"""
|
|
1073
|
+
Opens the plot context menu from the keyboard.
|
|
1074
|
+
|
|
1075
|
+
"""
|
|
1076
|
+
self.vbMenu.exec(self.widget.mapToGlobal(self.widget.rect().center()))
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _add_plot_area_resize_menu(self, window_menu):
|
|
1080
|
+
"""
|
|
1081
|
+
Adds fixed plot-area resize actions to the Window menu.
|
|
1082
|
+
|
|
1083
|
+
"""
|
|
1084
|
+
resize_menu = qtw.QMenu("&Resize", self)
|
|
1085
|
+
resize_menu.setObjectName("plotAreaResizeMenu")
|
|
1086
|
+
|
|
1087
|
+
for label, object_name, size in _PLOT_AREA_RESIZE_PRESETS:
|
|
1088
|
+
action = QtGui.QAction(
|
|
1089
|
+
_plot_area_size_icon(size),
|
|
1090
|
+
f"{label} ({size.width()} x {size.height()} px)",
|
|
1091
|
+
self,
|
|
1092
|
+
)
|
|
1093
|
+
action.setObjectName(object_name)
|
|
1094
|
+
action.setStatusTip(
|
|
1095
|
+
f"Resize the copied plot area to {size.width()} x {size.height()} px"
|
|
1096
|
+
)
|
|
1097
|
+
action.triggered.connect(
|
|
1098
|
+
lambda _checked=False, size=size: self.resize_plot_area(
|
|
1099
|
+
size.width(),
|
|
1100
|
+
size.height(),
|
|
1101
|
+
)
|
|
1102
|
+
)
|
|
1103
|
+
resize_menu.addAction(action)
|
|
1104
|
+
|
|
1105
|
+
resize_menu.addSeparator()
|
|
1106
|
+
custom_action = QtGui.QAction("&Custom...", self)
|
|
1107
|
+
custom_action.setObjectName("resizePlotAreaCustomAction")
|
|
1108
|
+
custom_action.setStatusTip("Resize the copied plot area to a custom pixel size")
|
|
1109
|
+
custom_action.triggered.connect(self.open_custom_plot_area_size_dialog)
|
|
1110
|
+
resize_menu.addAction(custom_action)
|
|
1111
|
+
|
|
1112
|
+
insert_before = next(
|
|
1113
|
+
(
|
|
1114
|
+
action for action in window_menu.actions()
|
|
1115
|
+
if action.text().replace("&", "") == "Minimize"
|
|
1116
|
+
),
|
|
1117
|
+
None,
|
|
1118
|
+
)
|
|
1119
|
+
if insert_before is None:
|
|
1120
|
+
window_menu.addMenu(resize_menu)
|
|
1121
|
+
return resize_menu
|
|
1122
|
+
|
|
1123
|
+
window_menu.insertMenu(insert_before, resize_menu)
|
|
1124
|
+
window_menu.insertSeparator(insert_before)
|
|
1125
|
+
return resize_menu
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
@QtCore.pyqtSlot()
|
|
1129
|
+
def open_custom_plot_area_size_dialog(self):
|
|
1130
|
+
"""
|
|
1131
|
+
Opens a dialog for resizing the copied plot area to an exact pixel size.
|
|
1132
|
+
|
|
1133
|
+
"""
|
|
1134
|
+
current_size = self._current_plot_area_size()
|
|
1135
|
+
|
|
1136
|
+
dialog = qtw.QDialog(self)
|
|
1137
|
+
dialog.setWindowTitle("Custom Plot Area Size")
|
|
1138
|
+
|
|
1139
|
+
form = qtw.QFormLayout(dialog)
|
|
1140
|
+
width_spin = qtw.QSpinBox(dialog)
|
|
1141
|
+
width_spin.setRange(1, 20_000)
|
|
1142
|
+
width_spin.setSuffix(" px")
|
|
1143
|
+
width_spin.setValue(max(1, current_size.width()))
|
|
1144
|
+
|
|
1145
|
+
height_spin = qtw.QSpinBox(dialog)
|
|
1146
|
+
height_spin.setRange(1, 20_000)
|
|
1147
|
+
height_spin.setSuffix(" px")
|
|
1148
|
+
height_spin.setValue(max(1, current_size.height()))
|
|
1149
|
+
|
|
1150
|
+
form.addRow("&Width:", width_spin)
|
|
1151
|
+
form.addRow("&Height:", height_spin)
|
|
1152
|
+
|
|
1153
|
+
buttons = qtw.QDialogButtonBox(
|
|
1154
|
+
qtw.QDialogButtonBox.StandardButton.Ok
|
|
1155
|
+
| qtw.QDialogButtonBox.StandardButton.Cancel,
|
|
1156
|
+
dialog,
|
|
1157
|
+
)
|
|
1158
|
+
buttons.accepted.connect(dialog.accept)
|
|
1159
|
+
buttons.rejected.connect(dialog.reject)
|
|
1160
|
+
form.addRow(buttons)
|
|
1161
|
+
|
|
1162
|
+
if dialog.exec() == qtw.QDialog.DialogCode.Accepted:
|
|
1163
|
+
self.resize_plot_area(width_spin.value(), height_spin.value())
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _current_plot_area_size(self):
|
|
1167
|
+
"""
|
|
1168
|
+
Returns the current copied plot-area size.
|
|
1169
|
+
|
|
1170
|
+
"""
|
|
1171
|
+
widget = getattr(self, "widget", None)
|
|
1172
|
+
if widget is None:
|
|
1173
|
+
return QtCore.QSize(1, 1)
|
|
1174
|
+
return widget.size()
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def resize_plot_area(self, width, height):
|
|
1178
|
+
"""
|
|
1179
|
+
Resizes the window so the copied plot area has the requested size.
|
|
1180
|
+
|
|
1181
|
+
"""
|
|
1182
|
+
target = QtCore.QSize(int(width), int(height))
|
|
1183
|
+
if target.width() < 1 or target.height() < 1:
|
|
1184
|
+
self.show_status("Plot area size must be at least 1 x 1 px.", 5000)
|
|
1185
|
+
return False
|
|
1186
|
+
|
|
1187
|
+
widget = getattr(self, "widget", None)
|
|
1188
|
+
if widget is None:
|
|
1189
|
+
self.show_status("No plot area available to resize.", 5000)
|
|
1190
|
+
return False
|
|
1191
|
+
|
|
1192
|
+
if self.isMaximized() or self.isFullScreen():
|
|
1193
|
+
self.showNormal()
|
|
1194
|
+
|
|
1195
|
+
self._resize_window_for_plot_area(target)
|
|
1196
|
+
actual = self._current_plot_area_size()
|
|
1197
|
+
if actual == target:
|
|
1198
|
+
self.show_status(
|
|
1199
|
+
f"Plot area resized to {actual.width()} x {actual.height()} px.",
|
|
1200
|
+
3000,
|
|
1201
|
+
)
|
|
1202
|
+
return True
|
|
1203
|
+
|
|
1204
|
+
self.show_status(
|
|
1205
|
+
"Plot area resized to "
|
|
1206
|
+
f"{actual.width()} x {actual.height()} px "
|
|
1207
|
+
f"(requested {target.width()} x {target.height()} px).",
|
|
1208
|
+
5000,
|
|
1209
|
+
)
|
|
1210
|
+
return False
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _resize_window_for_plot_area(self, target):
|
|
1214
|
+
"""
|
|
1215
|
+
Applies the top-level resize, refining after layouts have updated.
|
|
1216
|
+
|
|
1217
|
+
"""
|
|
1218
|
+
for _attempt in range(3):
|
|
1219
|
+
current = self._current_plot_area_size()
|
|
1220
|
+
delta = target - current
|
|
1221
|
+
if delta.isNull():
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
next_size = self.size() + delta
|
|
1225
|
+
next_size.setWidth(max(1, next_size.width()))
|
|
1226
|
+
next_size.setHeight(max(1, next_size.height()))
|
|
1227
|
+
self.resize(next_size)
|
|
1228
|
+
|
|
1229
|
+
app = qtw.QApplication.instance()
|
|
1230
|
+
if app is not None:
|
|
1231
|
+
app.processEvents(
|
|
1232
|
+
QtCore.QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def initAxes(self):
|
|
1237
|
+
"""
|
|
1238
|
+
Sets up the Data axes dock.
|
|
1239
|
+
Sets up which axis parameters are placed on for both 1d, 2d and more.
|
|
1240
|
+
|
|
1241
|
+
Refresh fetches the text of the dropdown menu to deciede which data to
|
|
1242
|
+
fetch
|
|
1243
|
+
|
|
1244
|
+
"""
|
|
1245
|
+
indep_params = self.param.depends_on_
|
|
1246
|
+
|
|
1247
|
+
self.param_dict = {self.param.name: self.param}
|
|
1248
|
+
|
|
1249
|
+
for param in indep_params:
|
|
1250
|
+
param_spec = unpack_param(self.ds, param)
|
|
1251
|
+
self.param_dict[param_spec.name] = param_spec
|
|
1252
|
+
|
|
1253
|
+
# Use of QDockWidget over QToolbar to allow proper widget placement
|
|
1254
|
+
self.axes_dock = QDock_context("Data axes", self)
|
|
1255
|
+
self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, self.axes_dock)
|
|
1256
|
+
self.axes_dock.setVisible(False)
|
|
1257
|
+
|
|
1258
|
+
# Widget production
|
|
1259
|
+
x_layout = self.axes_dock.addLayout()
|
|
1260
|
+
x_layout.addWidget(qtw.QLabel("x axis: "))
|
|
1261
|
+
x_dropdown = expandingComboBox()
|
|
1262
|
+
x_dropdown.addItems(indep_params)
|
|
1263
|
+
x_layout.addWidget(x_dropdown)
|
|
1264
|
+
|
|
1265
|
+
y_layout = self.axes_dock.addLayout()
|
|
1266
|
+
y_layout.addWidget(qtw.QLabel("y axis: "))
|
|
1267
|
+
y_dropdown = expandingComboBox()
|
|
1268
|
+
y_dropdown.addItems(indep_params)
|
|
1269
|
+
y_layout.addWidget(y_dropdown)
|
|
1270
|
+
|
|
1271
|
+
# Store for later use
|
|
1272
|
+
self.axis_dropdown = {"x": x_dropdown, "y": y_dropdown}
|
|
1273
|
+
|
|
1274
|
+
# Add options to menus and place correct axes using dataset.depends_on_.
|
|
1275
|
+
# This was set to match plottr
|
|
1276
|
+
if len(indep_params) == 1: # 1d plot
|
|
1277
|
+
self.axis_dropdown["y"].addItems([self.param.name])
|
|
1278
|
+
self.axis_dropdown["x"].addItems([self.param.name])
|
|
1279
|
+
|
|
1280
|
+
self.axis_dropdown["x"].setCurrentIndex(
|
|
1281
|
+
self.axis_dropdown["x"].findText(indep_params[0])
|
|
1282
|
+
)
|
|
1283
|
+
self.axis_dropdown["y"].setCurrentIndex(
|
|
1284
|
+
self.axis_dropdown["y"].findText(self.param.name)
|
|
1285
|
+
)
|
|
1286
|
+
else:
|
|
1287
|
+
self.axis_dropdown["x"].setCurrentIndex(
|
|
1288
|
+
self.axis_dropdown["x"].findText(indep_params[1])
|
|
1289
|
+
)
|
|
1290
|
+
self.axis_dropdown["y"].setCurrentIndex(
|
|
1291
|
+
self.axis_dropdown["y"].findText(indep_params[0])
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
# Connect slots.
|
|
1295
|
+
for axis in ["x", "y"]:
|
|
1296
|
+
self.axis_dropdown[axis].currentIndexChanged.connect(
|
|
1297
|
+
lambda index, axis=axis: self.change_axis(axis)
|
|
1298
|
+
)
|
|
1299
|
+
# Do not call the overridable ``axis_options`` property while the base
|
|
1300
|
+
# controls are still being constructed. Cut windows add their fixed-
|
|
1301
|
+
# axis picker only after this method returns.
|
|
1302
|
+
self._axis_selection = {
|
|
1303
|
+
axis: dropdown.currentText()
|
|
1304
|
+
for axis, dropdown in self.axis_dropdown.items()
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
# Produce seperations line as QDockWidget as none inbuilt
|
|
1308
|
+
sep = qtw.QFrame()
|
|
1309
|
+
sep.setFrameShape(qtw.QFrame.Shape.HLine)
|
|
1310
|
+
sep.setFrameShadow(qtw.QFrame.Shadow.Sunken)
|
|
1311
|
+
|
|
1312
|
+
self.axes_dock.addWidget(sep)
|
|
1313
|
+
|
|
1314
|
+
if getattr(self, "operation_kind", None) == "plot2d":
|
|
1315
|
+
self.axes_dock.content_layout.addStretch()
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def initOperations(self):
|
|
1319
|
+
"""
|
|
1320
|
+
Produces a right toolbar for viewing operations to perform during
|
|
1321
|
+
refresh
|
|
1322
|
+
|
|
1323
|
+
see ._widgets.operations for setup
|
|
1324
|
+
and
|
|
1325
|
+
qplot.tools.plot_tools for functions
|
|
1326
|
+
|
|
1327
|
+
"""
|
|
1328
|
+
self.oper_dock = QDock_context("Data operations", self)
|
|
1329
|
+
self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, self.oper_dock)
|
|
1330
|
+
self.oper_dock.setVisible(False)# Large window so toggle off by default
|
|
1331
|
+
|
|
1332
|
+
self.oper_widget = operations_widget(self)
|
|
1333
|
+
self.oper_widget.apply_but.clicked.connect(lambda: self.refreshWindow(force=True))
|
|
1334
|
+
self.oper_dock.addWidget(self.oper_widget)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def initMenu(self):
|
|
1338
|
+
"""
|
|
1339
|
+
Produces top menu bar.
|
|
1340
|
+
Allows toggle of toolbars and force refresh.
|
|
1341
|
+
|
|
1342
|
+
"""
|
|
1343
|
+
menu = self.menuBar()
|
|
1344
|
+
|
|
1345
|
+
file_menu = menu.addMenu("&File")
|
|
1346
|
+
|
|
1347
|
+
export_plot_action = getattr(self, "exportPlotAction", None)
|
|
1348
|
+
if export_plot_action is not None:
|
|
1349
|
+
file_menu.addAction(export_plot_action)
|
|
1350
|
+
|
|
1351
|
+
save_plot_pdf_action = getattr(self, "savePlotPdfAction", None)
|
|
1352
|
+
if save_plot_pdf_action is not None:
|
|
1353
|
+
file_menu.addAction(save_plot_pdf_action)
|
|
1354
|
+
|
|
1355
|
+
print_plot_action = getattr(self, "printPlotAction", None)
|
|
1356
|
+
if print_plot_action is not None:
|
|
1357
|
+
file_menu.addAction(print_plot_action)
|
|
1358
|
+
|
|
1359
|
+
if any(
|
|
1360
|
+
action is not None
|
|
1361
|
+
for action in (
|
|
1362
|
+
export_plot_action,
|
|
1363
|
+
save_plot_pdf_action,
|
|
1364
|
+
print_plot_action,
|
|
1365
|
+
)
|
|
1366
|
+
):
|
|
1367
|
+
file_menu.addSeparator()
|
|
1368
|
+
|
|
1369
|
+
copy_plot_image_action = getattr(self, "copyPlotImageAction", None)
|
|
1370
|
+
if copy_plot_image_action is not None:
|
|
1371
|
+
edit_menu = menu.addMenu("&Edit")
|
|
1372
|
+
edit_menu.addAction(copy_plot_image_action)
|
|
1373
|
+
|
|
1374
|
+
close_all_plots_action = create_action(
|
|
1375
|
+
"plots.close_all",
|
|
1376
|
+
self,
|
|
1377
|
+
status_tip="Close all open plot windows",
|
|
1378
|
+
)
|
|
1379
|
+
close_all_plots_action.triggered.connect(self.request_close_all_plots)
|
|
1380
|
+
file_menu.addAction(close_all_plots_action)
|
|
1381
|
+
|
|
1382
|
+
closeAction = create_action(
|
|
1383
|
+
"window.close",
|
|
1384
|
+
self,
|
|
1385
|
+
status_tip="Close this plot window",
|
|
1386
|
+
)
|
|
1387
|
+
closeAction.triggered.connect(self.close)
|
|
1388
|
+
file_menu.addAction(closeAction)
|
|
1389
|
+
|
|
1390
|
+
add_application_quit_action(
|
|
1391
|
+
self,
|
|
1392
|
+
file_menu,
|
|
1393
|
+
self.request_application_quit,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
window_menu = add_standard_window_controls(self)
|
|
1397
|
+
self._add_plot_area_resize_menu(window_menu)
|
|
1398
|
+
|
|
1399
|
+
options_menu = menu.addMenu("&Options")
|
|
1400
|
+
options_menu.addAction(
|
|
1401
|
+
create_preferences_action(self, self.show_preferences_dialog)
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
mouse_mode_action = getattr(self, "mouseModeAction", None)
|
|
1405
|
+
if mouse_mode_action is not None:
|
|
1406
|
+
self.vbMenu.removeAction(mouse_mode_action)
|
|
1407
|
+
options_menu.addSeparator()
|
|
1408
|
+
options_menu.addAction(mouse_mode_action)
|
|
1409
|
+
|
|
1410
|
+
main_menu = menu.addMenu("&View")
|
|
1411
|
+
|
|
1412
|
+
refreshAction = create_action("window.refresh", self)
|
|
1413
|
+
refreshAction.triggered.connect(lambda: self.refreshWindow(force=True))
|
|
1414
|
+
if hasattr(self, "get_mergables"): # Force refresh 1d line options
|
|
1415
|
+
refreshAction.triggered.connect(lambda: self.get_mergables.emit())
|
|
1416
|
+
main_menu.addAction(refreshAction)
|
|
1417
|
+
|
|
1418
|
+
toolbar_menu = self.createPopupMenu()
|
|
1419
|
+
toolbar_menu.setTitle("Panels and toolbars")
|
|
1420
|
+
main_menu.addMenu(toolbar_menu)
|
|
1421
|
+
add_help_menu(self)
|
|
1422
|
+
|
|
1423
|
+
###############################################################################
|
|
1424
|
+
#Other Methods
|
|
1425
|
+
|
|
1426
|
+
@staticmethod
|
|
1427
|
+
def formatNum(num : float, sf : int=3) -> str:
|
|
1428
|
+
"""
|
|
1429
|
+
Formats cursor point value to clean str display for user.
|
|
1430
|
+
|
|
1431
|
+
Parameters
|
|
1432
|
+
----------
|
|
1433
|
+
num : float
|
|
1434
|
+
Value at cursor point.
|
|
1435
|
+
sf : int, optional
|
|
1436
|
+
Number of significant figures to display. The default is 3.
|
|
1437
|
+
If this is changed, recomand increase labelWidth in initLables.
|
|
1438
|
+
|
|
1439
|
+
Returns
|
|
1440
|
+
-------
|
|
1441
|
+
str
|
|
1442
|
+
Formated string for display.
|
|
1443
|
+
|
|
1444
|
+
"""
|
|
1445
|
+
if not isfinite(num):
|
|
1446
|
+
if num != num:
|
|
1447
|
+
return "nan"
|
|
1448
|
+
return "-inf" if num < 0 else "inf"
|
|
1449
|
+
|
|
1450
|
+
try: # Get number of leading/following zeros
|
|
1451
|
+
exponent = floor(log10(abs(num)))
|
|
1452
|
+
except ValueError:
|
|
1453
|
+
return f"{0:.{sf}f}"
|
|
1454
|
+
|
|
1455
|
+
precision = max(sf - 1, 0)
|
|
1456
|
+
if exponent >= sf or exponent < 0:
|
|
1457
|
+
return f"{num:.{precision}e}"
|
|
1458
|
+
|
|
1459
|
+
formatted = f"{num:.{max(sf - exponent - 1, 0)}f}"
|
|
1460
|
+
rounded_exponent = floor(log10(abs(float(formatted))))
|
|
1461
|
+
if rounded_exponent != exponent:
|
|
1462
|
+
return f"{num:.{precision}e}"
|
|
1463
|
+
return formatted
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def format_cursor_number(self, value: float) -> str:
|
|
1467
|
+
"""Format a coordinate using the current cursor precision preference."""
|
|
1468
|
+
return self.formatNum(value, sf=self.config.get(CURSOR_SIGNIFICANT_FIGURES_KEY))
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def apply_cursor_label_widths(self) -> None:
|
|
1472
|
+
"""Reserve equal coordinate widths independent of the current values."""
|
|
1473
|
+
labels = self.__dict__.get("pos_labels", {})
|
|
1474
|
+
index_label = labels.get("index")
|
|
1475
|
+
if index_label is not None:
|
|
1476
|
+
index_width = max(
|
|
1477
|
+
index_label.fontMetrics().horizontalAdvance(
|
|
1478
|
+
f"[{digit * 7},{digit * 7}]"
|
|
1479
|
+
) + 12
|
|
1480
|
+
for digit in "0123456789"
|
|
1481
|
+
)
|
|
1482
|
+
index_label.setFixedWidth(index_width)
|
|
1483
|
+
coordinates = [labels[axis] for axis in ("x", "y", "z") if axis in labels]
|
|
1484
|
+
if not coordinates:
|
|
1485
|
+
return
|
|
1486
|
+
figures = self.config.get(CURSOR_SIGNIFICANT_FIGURES_KEY)
|
|
1487
|
+
# Allow a sign, decimal point, three exponent digits.
|
|
1488
|
+
# Measure every digit so proportional fonts also fit their widest one.
|
|
1489
|
+
width = max(
|
|
1490
|
+
label.fontMetrics().horizontalAdvance(
|
|
1491
|
+
f"{axis} = -{digit}.{digit * (figures - 1)}e+{digit * 3}"
|
|
1492
|
+
) + 12
|
|
1493
|
+
for label in coordinates
|
|
1494
|
+
for axis in ("x", "y", "z")
|
|
1495
|
+
for digit in "0123456789"
|
|
1496
|
+
)
|
|
1497
|
+
for label in coordinates:
|
|
1498
|
+
label.setFixedWidth(max(self._label_width, width))
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _set_cursor_index_label(self, text: str) -> None:
|
|
1502
|
+
"""
|
|
1503
|
+
Update the cursor index label when the toolbar includes one.
|
|
1504
|
+
|
|
1505
|
+
"""
|
|
1506
|
+
label = self.pos_labels.get("index")
|
|
1507
|
+
if label is not None:
|
|
1508
|
+
label.setText(text)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _cursor_1d_x_data(self):
|
|
1512
|
+
"""
|
|
1513
|
+
Return full processed X samples in view coordinates for cursor indices.
|
|
1514
|
+
|
|
1515
|
+
"""
|
|
1516
|
+
line = self.__dict__.get("line")
|
|
1517
|
+
if line is not None:
|
|
1518
|
+
data = _line_snap_data(line)
|
|
1519
|
+
return data.x_view if data is not None else None
|
|
1520
|
+
|
|
1521
|
+
return self.__dict__.get("axis_data", {}).get("x")
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _nearest_1d_array_index(self, x_value: float) -> int | None:
|
|
1525
|
+
"""
|
|
1526
|
+
Return the zero-based data index nearest to a cursor X coordinate.
|
|
1527
|
+
|
|
1528
|
+
"""
|
|
1529
|
+
if not isfinite(x_value):
|
|
1530
|
+
return None
|
|
1531
|
+
x_data = self._cursor_1d_x_data()
|
|
1532
|
+
if x_data is None:
|
|
1533
|
+
return None
|
|
1534
|
+
|
|
1535
|
+
nearest_index = None
|
|
1536
|
+
nearest_distance = None
|
|
1537
|
+
for index, value in enumerate(x_data):
|
|
1538
|
+
try:
|
|
1539
|
+
numeric_value = float(value)
|
|
1540
|
+
except (TypeError, ValueError):
|
|
1541
|
+
continue
|
|
1542
|
+
|
|
1543
|
+
if not isfinite(numeric_value):
|
|
1544
|
+
continue
|
|
1545
|
+
|
|
1546
|
+
distance = abs(numeric_value - x_value)
|
|
1547
|
+
if nearest_distance is None or distance < nearest_distance:
|
|
1548
|
+
nearest_index = index
|
|
1549
|
+
nearest_distance = distance
|
|
1550
|
+
|
|
1551
|
+
return nearest_index
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def update_theme(self, config):
|
|
1555
|
+
"""
|
|
1556
|
+
Updates theme of window to match main.
|
|
1557
|
+
|
|
1558
|
+
Parameters
|
|
1559
|
+
----------
|
|
1560
|
+
config : qplot.config
|
|
1561
|
+
Updated config file.
|
|
1562
|
+
|
|
1563
|
+
"""
|
|
1564
|
+
self.config = config
|
|
1565
|
+
|
|
1566
|
+
self.setStyleSheet(self.config.theme.main)
|
|
1567
|
+
self.config.theme.style_plotItem(self)
|
|
1568
|
+
plotWidget.apply_cursor_label_widths(self)
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def apply_current_settings(self):
|
|
1572
|
+
"""
|
|
1573
|
+
Applies config-backed settings handled directly by plot windows.
|
|
1574
|
+
|
|
1575
|
+
"""
|
|
1576
|
+
self.update_theme(self.config)
|
|
1577
|
+
self.apply_axis_major_tick_count_preference()
|
|
1578
|
+
self.apply_mouse_mode_preference()
|
|
1579
|
+
apply_colorbar_width = getattr(self, "apply_colorbar_width_preference", None)
|
|
1580
|
+
if callable(apply_colorbar_width):
|
|
1581
|
+
apply_colorbar_width()
|
|
1582
|
+
cursor_pos = self.__dict__.get("_last_cursor_scene_pos")
|
|
1583
|
+
if cursor_pos is not None:
|
|
1584
|
+
self.mouseMoved(cursor_pos)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _configured_mouse_mode(self):
|
|
1588
|
+
return self.config.get(MOUSE_MODE_KEY)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def apply_axis_major_tick_count_preference(self):
|
|
1592
|
+
"""Apply the target number of labelled major ticks to every plot axis."""
|
|
1593
|
+
|
|
1594
|
+
for side in ("left", "bottom", "right", "top"):
|
|
1595
|
+
_configure_major_ticks(
|
|
1596
|
+
self.plot.getAxis(side),
|
|
1597
|
+
self.config.get(AXIS_MAJOR_TICK_COUNT_KEY),
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
colorbar = getattr(self, "bar", None)
|
|
1601
|
+
get_axis = getattr(colorbar, "getAxis", None)
|
|
1602
|
+
if callable(get_axis):
|
|
1603
|
+
for side in ("left", "bottom", "right", "top"):
|
|
1604
|
+
axis = get_axis(side)
|
|
1605
|
+
if axis is not None:
|
|
1606
|
+
_configure_major_ticks(
|
|
1607
|
+
axis,
|
|
1608
|
+
self.config.get(AXIS_MAJOR_TICK_COUNT_KEY),
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
self.plot.update()
|
|
1612
|
+
viewport = getattr(self.widget, "viewport", None)
|
|
1613
|
+
if callable(viewport):
|
|
1614
|
+
viewport().update()
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def apply_mouse_mode_preference(self):
|
|
1618
|
+
"""
|
|
1619
|
+
Applies the configured pyqtgraph mouse mode to this plot view.
|
|
1620
|
+
|
|
1621
|
+
"""
|
|
1622
|
+
if hasattr(self, "vb"):
|
|
1623
|
+
self.vb.setLeftButtonAction(self._configured_mouse_mode())
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def change_mouse_mode(self, mode):
|
|
1627
|
+
"""
|
|
1628
|
+
Persists a mouse mode change made from a plot-window menu.
|
|
1629
|
+
|
|
1630
|
+
"""
|
|
1631
|
+
if mode not in {"pan", "rect"}:
|
|
1632
|
+
return False
|
|
1633
|
+
|
|
1634
|
+
main_window = main_window_for(self)
|
|
1635
|
+
main_config = getattr(main_window, "config", None)
|
|
1636
|
+
target_config = self.config
|
|
1637
|
+
if main_config is self.config:
|
|
1638
|
+
target_config = main_config
|
|
1639
|
+
|
|
1640
|
+
current_mode = target_config.get(MOUSE_MODE_KEY)
|
|
1641
|
+
|
|
1642
|
+
def rollback():
|
|
1643
|
+
if hasattr(self, "vb"):
|
|
1644
|
+
self.vb.setLeftButtonAction(current_mode)
|
|
1645
|
+
|
|
1646
|
+
mouse_mode_action = self.__dict__.get("mouseModeAction")
|
|
1647
|
+
menu = (
|
|
1648
|
+
mouse_mode_action.menu()
|
|
1649
|
+
if mouse_mode_action is not None
|
|
1650
|
+
else None
|
|
1651
|
+
)
|
|
1652
|
+
if menu is None:
|
|
1653
|
+
return
|
|
1654
|
+
mode_actions = getattr(
|
|
1655
|
+
self.__dict__.get("vbMenu"),
|
|
1656
|
+
"mouseModes",
|
|
1657
|
+
menu.actions(),
|
|
1658
|
+
)
|
|
1659
|
+
blocked_states = [
|
|
1660
|
+
mode_action.blockSignals(True)
|
|
1661
|
+
for mode_action in mode_actions
|
|
1662
|
+
]
|
|
1663
|
+
try:
|
|
1664
|
+
for mode_action, action_mode in zip(
|
|
1665
|
+
mode_actions,
|
|
1666
|
+
("pan", "rect"),
|
|
1667
|
+
strict=False,
|
|
1668
|
+
):
|
|
1669
|
+
mode_action.setChecked(action_mode == current_mode)
|
|
1670
|
+
finally:
|
|
1671
|
+
for mode_action, signals_were_blocked in zip(
|
|
1672
|
+
mode_actions,
|
|
1673
|
+
blocked_states,
|
|
1674
|
+
strict=True,
|
|
1675
|
+
):
|
|
1676
|
+
mode_action.blockSignals(signals_were_blocked)
|
|
1677
|
+
|
|
1678
|
+
if current_mode != mode:
|
|
1679
|
+
error_owner = main_window if main_config is target_config else self
|
|
1680
|
+
if not persist_config_value(
|
|
1681
|
+
error_owner,
|
|
1682
|
+
target_config,
|
|
1683
|
+
MOUSE_MODE_KEY,
|
|
1684
|
+
mode,
|
|
1685
|
+
"the mouse mode",
|
|
1686
|
+
rollback,
|
|
1687
|
+
):
|
|
1688
|
+
return False
|
|
1689
|
+
|
|
1690
|
+
if (
|
|
1691
|
+
main_window is not None
|
|
1692
|
+
and main_window is not self
|
|
1693
|
+
and main_config is target_config
|
|
1694
|
+
and hasattr(main_window, "apply_current_settings")
|
|
1695
|
+
):
|
|
1696
|
+
main_window.apply_current_settings()
|
|
1697
|
+
else:
|
|
1698
|
+
self.apply_mouse_mode_preference()
|
|
1699
|
+
return True
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _connect_mouse_mode_menu_to_preferences(self):
|
|
1703
|
+
menu = self.mouseModeAction.menu() if self.mouseModeAction is not None else None
|
|
1704
|
+
if menu is None:
|
|
1705
|
+
return
|
|
1706
|
+
|
|
1707
|
+
mode_actions = getattr(
|
|
1708
|
+
self.__dict__.get("vbMenu"),
|
|
1709
|
+
"mouseModes",
|
|
1710
|
+
menu.actions(),
|
|
1711
|
+
)
|
|
1712
|
+
for action, mode in zip(
|
|
1713
|
+
mode_actions,
|
|
1714
|
+
("pan", "rect"),
|
|
1715
|
+
strict=False,
|
|
1716
|
+
):
|
|
1717
|
+
action.triggered.connect(
|
|
1718
|
+
lambda _checked=False, mode=mode: self.change_mouse_mode(mode)
|
|
1719
|
+
)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def show_preferences_dialog(self, initial_tab: str | None = None):
|
|
1723
|
+
"""
|
|
1724
|
+
Opens the shared preferences dialog from a plot window.
|
|
1725
|
+
|
|
1726
|
+
"""
|
|
1727
|
+
main_window = main_window_for(self)
|
|
1728
|
+
owner = main_window if main_window is not None else self
|
|
1729
|
+
config = getattr(owner, "config", self.config)
|
|
1730
|
+
|
|
1731
|
+
if config is not self.config:
|
|
1732
|
+
self.config = config
|
|
1733
|
+
|
|
1734
|
+
dialog = PreferencesDialog(config, self)
|
|
1735
|
+
if initial_tab is not None:
|
|
1736
|
+
tab_widget = dialog.findChild(qtw.QTabWidget)
|
|
1737
|
+
if tab_widget is not None:
|
|
1738
|
+
for index in range(tab_widget.count()):
|
|
1739
|
+
if tab_widget.tabText(index) == initial_tab:
|
|
1740
|
+
tab_widget.setCurrentIndex(index)
|
|
1741
|
+
break
|
|
1742
|
+
if initial_tab == "Runtime":
|
|
1743
|
+
QtCore.QTimer.singleShot(
|
|
1744
|
+
0,
|
|
1745
|
+
dialog.maxFullHeatmapPointsSpin.setFocus,
|
|
1746
|
+
)
|
|
1747
|
+
if hasattr(owner, "apply_current_settings"):
|
|
1748
|
+
dialog.preferencesApplied.connect(owner.apply_current_settings)
|
|
1749
|
+
else:
|
|
1750
|
+
dialog.preferencesApplied.connect(self.apply_current_settings)
|
|
1751
|
+
dialog.preferencesApplied.connect(
|
|
1752
|
+
lambda: self.show_status("Preferences saved.", 3000)
|
|
1753
|
+
)
|
|
1754
|
+
try:
|
|
1755
|
+
dialog.exec()
|
|
1756
|
+
finally:
|
|
1757
|
+
dialog.deleteLater()
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
@staticmethod
|
|
1761
|
+
def request_application_quit():
|
|
1762
|
+
"""
|
|
1763
|
+
Closes the main window first so its normal quit handling still applies.
|
|
1764
|
+
|
|
1765
|
+
"""
|
|
1766
|
+
app = qtw.QApplication.instance()
|
|
1767
|
+
if app is None:
|
|
1768
|
+
return
|
|
1769
|
+
|
|
1770
|
+
for window in app.topLevelWidgets():
|
|
1771
|
+
if window.__class__.__name__ == "MainWindow":
|
|
1772
|
+
quit_application = getattr(window, "quit_application", None)
|
|
1773
|
+
if callable(quit_application):
|
|
1774
|
+
quit_application()
|
|
1775
|
+
else:
|
|
1776
|
+
window.close()
|
|
1777
|
+
return
|
|
1778
|
+
|
|
1779
|
+
app.closeAllWindows()
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
@staticmethod
|
|
1783
|
+
def request_close_all_plots():
|
|
1784
|
+
"""
|
|
1785
|
+
Closes all plot windows through the main window.
|
|
1786
|
+
|
|
1787
|
+
"""
|
|
1788
|
+
app = qtw.QApplication.instance()
|
|
1789
|
+
if app is None:
|
|
1790
|
+
return
|
|
1791
|
+
|
|
1792
|
+
for window in app.topLevelWidgets():
|
|
1793
|
+
if hasattr(window, "closeAll"):
|
|
1794
|
+
window.closeAll()
|
|
1795
|
+
return
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
#Note, this is an overwrite of core QMainWindow function
|
|
1799
|
+
def createPopupMenu(self) -> "qtw.QMenu":
|
|
1800
|
+
"""
|
|
1801
|
+
Produces a pop-up/context menu.
|
|
1802
|
+
Displays all toolbars/dockwidgets to allow for toggle on/off
|
|
1803
|
+
|
|
1804
|
+
Returns
|
|
1805
|
+
-------
|
|
1806
|
+
menu : PyQt6.QtWidgets.QMenu
|
|
1807
|
+
Context menu to be displayed.
|
|
1808
|
+
|
|
1809
|
+
"""
|
|
1810
|
+
menu = qtw.QMenu(self)
|
|
1811
|
+
|
|
1812
|
+
# Fetching QToolBar and QDockWidget
|
|
1813
|
+
widgets = self.findChildren((qtw.QToolBar, qtw.QDockWidget))
|
|
1814
|
+
|
|
1815
|
+
# Set actions
|
|
1816
|
+
for widget in widgets:
|
|
1817
|
+
action = widget.toggleViewAction()
|
|
1818
|
+
if isinstance(action, QtGui.QAction):
|
|
1819
|
+
command = toolbar_toggle_command_spec(widget.windowTitle())
|
|
1820
|
+
if command is not None:
|
|
1821
|
+
self.register_shortcut(
|
|
1822
|
+
action,
|
|
1823
|
+
command,
|
|
1824
|
+
)
|
|
1825
|
+
menu.addAction(action)
|
|
1826
|
+
|
|
1827
|
+
return menu
|
|
1828
|
+
|
|
1829
|
+
@property
|
|
1830
|
+
def axis_options(self) -> dict:
|
|
1831
|
+
"""
|
|
1832
|
+
Returns the currently selected axis in the axis dropdown boxes
|
|
1833
|
+
|
|
1834
|
+
Returns
|
|
1835
|
+
-------
|
|
1836
|
+
dict{str: str}
|
|
1837
|
+
Dictionary in form {axis_name: parameter_name}.
|
|
1838
|
+
|
|
1839
|
+
"""
|
|
1840
|
+
return {k: v.currentText() for k, v in self.axis_dropdown.items()}
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
###############################################################################
|
|
1844
|
+
#Events
|
|
1845
|
+
|
|
1846
|
+
def keyPressEvent(self, event):
|
|
1847
|
+
if event.key() == QtCore.Qt.Key.Key_Escape and self.__dict__.get("marquee") is not None:
|
|
1848
|
+
self.clear_marquee()
|
|
1849
|
+
event.accept()
|
|
1850
|
+
return
|
|
1851
|
+
|
|
1852
|
+
super().keyPressEvent(event)
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
@QtCore.pyqtSlot(bool)
|
|
1856
|
+
def closeEvent(self, event):
|
|
1857
|
+
"""
|
|
1858
|
+
Handles close admin on close event.
|
|
1859
|
+
Sends signal, closed, to Main window to further handle event.
|
|
1860
|
+
|
|
1861
|
+
Parameters
|
|
1862
|
+
----------
|
|
1863
|
+
Unused but required by slot.
|
|
1864
|
+
|
|
1865
|
+
"""
|
|
1866
|
+
# Qt leaves child windows visible when their parent is merely closed.
|
|
1867
|
+
# Reject editors before releasing the dataset so they cannot act on a
|
|
1868
|
+
# closed plot. Keep their widgets alive for retained overlay sources.
|
|
1869
|
+
for dialog in self.findChildren(qtw.QDialog):
|
|
1870
|
+
dialog.reject()
|
|
1871
|
+
dispose_export_dialog = getattr(
|
|
1872
|
+
self,
|
|
1873
|
+
"_dispose_plot_export_dialog",
|
|
1874
|
+
None,
|
|
1875
|
+
)
|
|
1876
|
+
if callable(dispose_export_dialog):
|
|
1877
|
+
dispose_export_dialog()
|
|
1878
|
+
if self.__dict__.get("_merged_trace_users", 0) <= 0:
|
|
1879
|
+
self.monitor.stop()
|
|
1880
|
+
self._refresh_pending = False
|
|
1881
|
+
self._refresh_pending_force = False
|
|
1882
|
+
worker = self.__dict__.get("worker")
|
|
1883
|
+
cancel = getattr(worker, "cancel", None)
|
|
1884
|
+
if callable(cancel):
|
|
1885
|
+
cancel()
|
|
1886
|
+
self.visible = False
|
|
1887
|
+
self._closed = True
|
|
1888
|
+
self.plot_state_overlay.hide()
|
|
1889
|
+
self.closed.emit(self)
|
|
1890
|
+
|
|
1891
|
+
if (
|
|
1892
|
+
self.__dict__.get("_merged_trace_users", 0) > 0
|
|
1893
|
+
and self._refresh_monitor_required(self.ds)
|
|
1894
|
+
and not self.monitor.isActive()
|
|
1895
|
+
):
|
|
1896
|
+
self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
@QtCore.pyqtSlot(object)
|
|
1900
|
+
def mouseMoved(self, pos):
|
|
1901
|
+
"""
|
|
1902
|
+
Handles event for moving mouse over plot widget. Updates labels defined
|
|
1903
|
+
in self.initLabels().
|
|
1904
|
+
|
|
1905
|
+
Parameters
|
|
1906
|
+
----------
|
|
1907
|
+
pos : PyQt6.<something?>
|
|
1908
|
+
The cursor position object.
|
|
1909
|
+
|
|
1910
|
+
"""
|
|
1911
|
+
self._last_cursor_scene_pos = pos
|
|
1912
|
+
# Ignore if not in plot widget
|
|
1913
|
+
if not self.plot.sceneBoundingRect().contains(pos):
|
|
1914
|
+
self._set_cursor_index_label("")
|
|
1915
|
+
if hasattr(self, "hide_hover_pixel_outline"):
|
|
1916
|
+
self.hide_hover_pixel_outline()
|
|
1917
|
+
z_label = self.pos_labels.get("z")
|
|
1918
|
+
if z_label is not None:
|
|
1919
|
+
z_label.setText("z =")
|
|
1920
|
+
return
|
|
1921
|
+
|
|
1922
|
+
# get x, y values.
|
|
1923
|
+
try:
|
|
1924
|
+
viewbox_getter = (
|
|
1925
|
+
getattr(self, "_primary_heatmap_viewbox", None)
|
|
1926
|
+
if getattr(self, "operation_kind", None) == "plot2d" else None
|
|
1927
|
+
)
|
|
1928
|
+
except RuntimeError:
|
|
1929
|
+
viewbox_getter = None
|
|
1930
|
+
coordinate_viewbox = (
|
|
1931
|
+
viewbox_getter() if callable(viewbox_getter) else self.plot.vb
|
|
1932
|
+
)
|
|
1933
|
+
if coordinate_viewbox is None:
|
|
1934
|
+
coordinate_viewbox = self.plot.vb
|
|
1935
|
+
line = self.__dict__.get("line")
|
|
1936
|
+
line_axes = ("x", "y")
|
|
1937
|
+
line_viewbox_getter = getattr(line, "getViewBox", None)
|
|
1938
|
+
if not callable(viewbox_getter) and callable(line_viewbox_getter):
|
|
1939
|
+
try:
|
|
1940
|
+
line_viewbox = line_viewbox_getter()
|
|
1941
|
+
except RuntimeError:
|
|
1942
|
+
line_viewbox = None
|
|
1943
|
+
if line_viewbox is not None:
|
|
1944
|
+
coordinate_viewbox = line_viewbox
|
|
1945
|
+
line_axes = (
|
|
1946
|
+
self._axis_scale_axis_for_line(line, "x"),
|
|
1947
|
+
self._axis_scale_axis_for_line(line, "y"),
|
|
1948
|
+
)
|
|
1949
|
+
mousePoint = coordinate_viewbox.mapSceneToView(pos)
|
|
1950
|
+
try:
|
|
1951
|
+
semantic_axes_getter = getattr(
|
|
1952
|
+
self,
|
|
1953
|
+
"_primary_heatmap_semantic_axes",
|
|
1954
|
+
None,
|
|
1955
|
+
) if getattr(self, "operation_kind", None) == "plot2d" else None
|
|
1956
|
+
except RuntimeError:
|
|
1957
|
+
semantic_axes_getter = None
|
|
1958
|
+
semantic_x, semantic_y = (
|
|
1959
|
+
semantic_axes_getter()
|
|
1960
|
+
if callable(semantic_axes_getter)
|
|
1961
|
+
else line_axes
|
|
1962
|
+
)
|
|
1963
|
+
physical_x = cast(float, self.view_to_data(semantic_x, mousePoint.x()))
|
|
1964
|
+
physical_y = cast(float, self.view_to_data(semantic_y, mousePoint.y()))
|
|
1965
|
+
|
|
1966
|
+
# Format text into a easy to read format
|
|
1967
|
+
index_txt = ""
|
|
1968
|
+
x_txt = f"x = {self.format_cursor_number(physical_x)}"
|
|
1969
|
+
y_txt = f"y = {self.format_cursor_number(physical_y)}"
|
|
1970
|
+
|
|
1971
|
+
# For 2d plots.
|
|
1972
|
+
if self.pos_labels.get("z", 0):
|
|
1973
|
+
sample_at = getattr(self, "heatmap_sample_at", None)
|
|
1974
|
+
sample = (
|
|
1975
|
+
sample_at(mousePoint.x(), mousePoint.y())
|
|
1976
|
+
if callable(sample_at)
|
|
1977
|
+
else None
|
|
1978
|
+
)
|
|
1979
|
+
if sample is not None:
|
|
1980
|
+
i, j, x, y, z = sample
|
|
1981
|
+
index_txt = f"[{i},{j}]"
|
|
1982
|
+
x_txt = f"x = {self.format_cursor_number(x)}"
|
|
1983
|
+
y_txt = f"y = {self.format_cursor_number(y)}"
|
|
1984
|
+
self.pos_labels["z"].setText(f"z = {self.format_cursor_number(z)}")
|
|
1985
|
+
if hasattr(self, "show_hover_pixel_outline"):
|
|
1986
|
+
self.show_hover_pixel_outline(i, j)
|
|
1987
|
+
else:
|
|
1988
|
+
self.z_index = [i, j]
|
|
1989
|
+
else:
|
|
1990
|
+
self.pos_labels["z"].setText("z =")
|
|
1991
|
+
if hasattr(self, "hide_hover_pixel_outline"):
|
|
1992
|
+
self.hide_hover_pixel_outline()
|
|
1993
|
+
else:
|
|
1994
|
+
self.z_index = None
|
|
1995
|
+
else:
|
|
1996
|
+
index = self._nearest_1d_array_index(mousePoint.x())
|
|
1997
|
+
if index is not None:
|
|
1998
|
+
index_txt = f"[{index}]"
|
|
1999
|
+
|
|
2000
|
+
# Update text
|
|
2001
|
+
self._set_cursor_index_label(index_txt)
|
|
2002
|
+
self.pos_labels["x"].setText(x_txt)
|
|
2003
|
+
self.pos_labels["y"].setText(y_txt)
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
@QtCore.pyqtSlot(float)
|
|
2007
|
+
def monitorIntervalChanged(self, interval):
|
|
2008
|
+
"""
|
|
2009
|
+
Handles event for self.spinBox value change.
|
|
2010
|
+
Updates refresh timer.
|
|
2011
|
+
|
|
2012
|
+
Parameters
|
|
2013
|
+
----------
|
|
2014
|
+
interval : float
|
|
2015
|
+
Time in seconds to change refresh timer to.
|
|
2016
|
+
|
|
2017
|
+
"""
|
|
2018
|
+
remember_refresh_interval(self.spinBox, interval)
|
|
2019
|
+
self.monitor.stop()
|
|
2020
|
+
if interval > 0:
|
|
2021
|
+
self.monitor.start(max(1, round(interval * 1000)))
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def add_or_remove_operations(self, key : str, func : callable = None):
|
|
2025
|
+
"""
|
|
2026
|
+
Adds a callable function to be passed to the operations for the worker
|
|
2027
|
+
|
|
2028
|
+
Parameters
|
|
2029
|
+
----------
|
|
2030
|
+
key : str
|
|
2031
|
+
A key to track the function.
|
|
2032
|
+
func : callable, optional
|
|
2033
|
+
Function to be added to the tracker. If None is passed instead of a
|
|
2034
|
+
callable, the key is instead removed from the tracker.
|
|
2035
|
+
|
|
2036
|
+
"""
|
|
2037
|
+
# Remove item if func is none
|
|
2038
|
+
if func is None and self.operations.get(key, 0) != 0:
|
|
2039
|
+
self.operations.pop(key)
|
|
2040
|
+
else: # otherwise add to list
|
|
2041
|
+
self.operations[key] = func
|
|
2042
|
+
|
|
2043
|
+
# Force update
|
|
2044
|
+
self.refreshWindow(force=True)
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
@QtCore.pyqtSlot()
|
|
2048
|
+
def change_axis(self, key : str):
|
|
2049
|
+
"""
|
|
2050
|
+
Event handler for axis dropdown menu selection change.
|
|
2051
|
+
Switches the axes based on user selection and calls a forced refresh.
|
|
2052
|
+
|
|
2053
|
+
Parameters
|
|
2054
|
+
----------
|
|
2055
|
+
key : str
|
|
2056
|
+
The axis label (x or y) which has been changed.
|
|
2057
|
+
|
|
2058
|
+
Raises
|
|
2059
|
+
------
|
|
2060
|
+
ValueError
|
|
2061
|
+
Error catch for rare cases where dropdown menus fail to correctly
|
|
2062
|
+
update.
|
|
2063
|
+
|
|
2064
|
+
"""
|
|
2065
|
+
previous = getattr(self, "_axis_selection", self.axis_options)
|
|
2066
|
+
duplicates = [k for k, v in self.axis_dropdown.items()
|
|
2067
|
+
if self.axis_dropdown[key].currentText() == v.currentText()
|
|
2068
|
+
and k != key
|
|
2069
|
+
]
|
|
2070
|
+
|
|
2071
|
+
# If both boxes show the same value, switch second box to original value
|
|
2072
|
+
if len(duplicates) == 1:
|
|
2073
|
+
self.axis_dropdown[duplicates[0]].blockSignals(True)
|
|
2074
|
+
|
|
2075
|
+
self.axis_dropdown[duplicates[0]].setCurrentIndex(
|
|
2076
|
+
self.axis_dropdown[duplicates[0]].findText(previous[key])
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
self.axis_dropdown[duplicates[0]].blockSignals(False)
|
|
2080
|
+
|
|
2081
|
+
self._axis_selection = self.axis_options
|
|
2082
|
+
self.refreshWindow(force=True)
|