qplotter 1.6.0b3__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 +63 -0
- qplot/_metadata.py +12 -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 +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -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 +2330 -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 +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -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 +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -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 +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -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 +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from math import isclose, isfinite, log10
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
import pyqtgraph as pg
|
|
9
|
+
from PyQt6 import QtCore
|
|
10
|
+
from PyQt6 import QtWidgets as qtw
|
|
11
|
+
from pyqtgraph.graphicsItems.ViewBox import axisCtrlTemplate_generic
|
|
12
|
+
|
|
13
|
+
_AxisName = Literal["x", "y", "x2", "y2"]
|
|
14
|
+
_AXIS_MENU_ITEMS: tuple[tuple[_AxisName, str], ...] = (
|
|
15
|
+
("x", "X axis"),
|
|
16
|
+
("y", "Y axis"),
|
|
17
|
+
)
|
|
18
|
+
_AXIS_SPECS: tuple[tuple[_AxisName, str, str], ...] = (
|
|
19
|
+
("x", "X", "bottom"),
|
|
20
|
+
("y", "Y", "left"),
|
|
21
|
+
("x2", "X2", "top"),
|
|
22
|
+
("y2", "Y2", "right"),
|
|
23
|
+
)
|
|
24
|
+
_AXIS_DIMENSIONS: dict[_AxisName, Literal["x", "y"]] = {
|
|
25
|
+
"x": "x",
|
|
26
|
+
"y": "y",
|
|
27
|
+
"x2": "x",
|
|
28
|
+
"y2": "y",
|
|
29
|
+
}
|
|
30
|
+
_AXIS_SIDES: dict[_AxisName, str] = {
|
|
31
|
+
"x": "bottom",
|
|
32
|
+
"y": "left",
|
|
33
|
+
"x2": "top",
|
|
34
|
+
"y2": "right",
|
|
35
|
+
}
|
|
36
|
+
_AXIS_ALIASES: dict[str, _AxisName] = {
|
|
37
|
+
"x": "x",
|
|
38
|
+
"bottom": "x",
|
|
39
|
+
"y": "y",
|
|
40
|
+
"left": "y",
|
|
41
|
+
"x2": "x2",
|
|
42
|
+
"top": "x2",
|
|
43
|
+
"y2": "y2",
|
|
44
|
+
"right": "y2",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_NumericValue = float | npt.ArrayLike
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
class _PlotAxisScalingBase(qtw.QMainWindow):
|
|
51
|
+
_axis_scale_controls: dict[_AxisName, Any]
|
|
52
|
+
_axis_scale_dialog: qtw.QDialog | None
|
|
53
|
+
_axis_scale_tabs: qtw.QTabWidget | None
|
|
54
|
+
plot: Any
|
|
55
|
+
right_vb: Any
|
|
56
|
+
vb: Any
|
|
57
|
+
vbMenu: Any
|
|
58
|
+
|
|
59
|
+
def _context_menu_action(self, text: str) -> Any | None: ...
|
|
60
|
+
def _view_range_changed_programmatically(self) -> None: ...
|
|
61
|
+
else:
|
|
62
|
+
class _PlotAxisScalingBase:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _manual_axis_view_limits_are_usable(
|
|
67
|
+
viewbox: Any,
|
|
68
|
+
axis_number: int,
|
|
69
|
+
values: tuple[float, float] | list[float],
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""Preflight PyQtGraph's range arithmetic before it changes view state."""
|
|
72
|
+
lower, upper = values
|
|
73
|
+
span = upper - lower
|
|
74
|
+
endpoint_sum = lower + upper
|
|
75
|
+
if (
|
|
76
|
+
not all(isfinite(value) for value in (lower, upper, span, endpoint_sum))
|
|
77
|
+
or span <= 0
|
|
78
|
+
):
|
|
79
|
+
return False
|
|
80
|
+
# ViewBox expands narrow positive-offset ranges by this quantization
|
|
81
|
+
# allowance before applying its limits. Check the same arithmetic without
|
|
82
|
+
# disabling Auto or publishing an intermediate invalid target range.
|
|
83
|
+
allowance = endpoint_sum * 1.5e-15
|
|
84
|
+
if span < 2 * allowance:
|
|
85
|
+
lower -= allowance
|
|
86
|
+
upper += allowance
|
|
87
|
+
if not all(isfinite(value) for value in (lower, upper, upper - lower)):
|
|
88
|
+
return False
|
|
89
|
+
effective_limits = getattr(viewbox, "_effectiveLimits", None)
|
|
90
|
+
if callable(effective_limits):
|
|
91
|
+
minimum, maximum = effective_limits()[axis_number]
|
|
92
|
+
if minimum is not None and lower < minimum:
|
|
93
|
+
return False
|
|
94
|
+
if maximum is not None and upper > maximum:
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _disable_native_axis_auto(viewbox: Any, axis_constant: int) -> None:
|
|
100
|
+
"""Let qPlot replace pending native auto bounds without applying them."""
|
|
101
|
+
pending = viewbox._autoRangeNeedsUpdate
|
|
102
|
+
viewbox._autoRangeNeedsUpdate = False
|
|
103
|
+
try:
|
|
104
|
+
viewbox.enableAutoRange(axis_constant, False)
|
|
105
|
+
finally:
|
|
106
|
+
viewbox._autoRangeNeedsUpdate = pending and any(
|
|
107
|
+
mode is not False for mode in viewbox.autoRangeEnabled()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def _axis_bounds_without_dynamic_clipping(
|
|
113
|
+
viewbox: Any, items: list[Any] | None,
|
|
114
|
+
) -> Iterator[None]:
|
|
115
|
+
"""Measure processed curves without clipping them to the previous Y view."""
|
|
116
|
+
changed = []
|
|
117
|
+
try:
|
|
118
|
+
for item in items if items is not None else viewbox.addedItems:
|
|
119
|
+
if not isinstance(item, pg.PlotDataItem):
|
|
120
|
+
continue
|
|
121
|
+
limit = item.opts.get("dynamicRangeLimit")
|
|
122
|
+
if limit is None:
|
|
123
|
+
continue
|
|
124
|
+
changed.append((item, limit))
|
|
125
|
+
item.opts["dynamicRangeLimit"] = None
|
|
126
|
+
item._datasetDisplay = None
|
|
127
|
+
item.updateItems()
|
|
128
|
+
yield
|
|
129
|
+
finally:
|
|
130
|
+
for item, limit in reversed(changed):
|
|
131
|
+
item.opts["dynamicRangeLimit"] = limit
|
|
132
|
+
item._datasetDisplay = None
|
|
133
|
+
item.updateItems()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _axis_scale_power_text(scale: float) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Return a compact HTML power-of-ten label for an axis display scale.
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
if not isfinite(scale) or scale <= 0 or isclose(scale, 1.0):
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
exponent = round(log10(scale))
|
|
145
|
+
if isclose(scale, 10**exponent, rel_tol=1e-9, abs_tol=0.0):
|
|
146
|
+
return f"10<sup>{exponent}</sup>"
|
|
147
|
+
|
|
148
|
+
return f"{scale:g}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _flush_axis_draw_specs(axis: pg.AxisItem, specs: Any) -> Any:
|
|
152
|
+
"""Move pyqtgraph's axis line onto the edge of its linked view."""
|
|
153
|
+
|
|
154
|
+
if specs is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
endpoint_offsets = {
|
|
158
|
+
"left": (QtCore.QPointF(1.0, 1.0), QtCore.QPointF(1.0, -1.0)),
|
|
159
|
+
"right": (QtCore.QPointF(-1.0, 1.0), QtCore.QPointF(-1.0, -1.0)),
|
|
160
|
+
"top": (QtCore.QPointF(1.0, 1.0), QtCore.QPointF(-1.0, 1.0)),
|
|
161
|
+
"bottom": (QtCore.QPointF(1.0, -1.0), QtCore.QPointF(-1.0, -1.0)),
|
|
162
|
+
}
|
|
163
|
+
offsets = endpoint_offsets.get(axis.orientation)
|
|
164
|
+
if offsets is None:
|
|
165
|
+
return specs
|
|
166
|
+
|
|
167
|
+
axis_spec, tick_specs, text_specs = specs
|
|
168
|
+
pen, start, end = axis_spec
|
|
169
|
+
start_offset, end_offset = offsets
|
|
170
|
+
flush_axis_spec = (
|
|
171
|
+
pen,
|
|
172
|
+
QtCore.QPointF(start) + start_offset,
|
|
173
|
+
QtCore.QPointF(end) + end_offset,
|
|
174
|
+
)
|
|
175
|
+
return flush_axis_spec, tick_specs, text_specs
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _install_flush_axis_draw_specs(axis: pg.AxisItem) -> None:
|
|
179
|
+
"""Remove the one-pixel frame standoff from an existing axis item."""
|
|
180
|
+
|
|
181
|
+
if getattr(axis, "_qplot_flush_axis_draw_specs", False):
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
original_generate_draw_specs = axis.generateDrawSpecs
|
|
185
|
+
|
|
186
|
+
def generate_draw_specs(painter: Any) -> Any:
|
|
187
|
+
return _flush_axis_draw_specs(
|
|
188
|
+
axis,
|
|
189
|
+
original_generate_draw_specs(painter),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
axis.generateDrawSpecs = generate_draw_specs
|
|
193
|
+
axis._qplot_flush_axis_draw_specs = True
|
|
194
|
+
axis.picture = None
|
|
195
|
+
axis.update()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _PowerScaledAxisItem(pg.AxisItem):
|
|
199
|
+
"""
|
|
200
|
+
Display pyqtgraph's auto SI scaling as powers of ten in the axis unit.
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def labelString(self) -> str:
|
|
205
|
+
if self.autoSIPrefix and not isclose(self.autoSIPrefixScale, 1.0):
|
|
206
|
+
unit_scale = 1.0 / self.autoSIPrefixScale
|
|
207
|
+
else:
|
|
208
|
+
unit_scale = 1.0
|
|
209
|
+
|
|
210
|
+
scale_text = _axis_scale_power_text(unit_scale)
|
|
211
|
+
if self.labelUnits == "":
|
|
212
|
+
units = f"({scale_text})" if scale_text else ""
|
|
213
|
+
elif scale_text:
|
|
214
|
+
units = f"({scale_text} {self.labelUnits})"
|
|
215
|
+
else:
|
|
216
|
+
units = f"({self.labelUnitPrefix}{self.labelUnits})"
|
|
217
|
+
|
|
218
|
+
text = f"{self.labelText} {units}"
|
|
219
|
+
style = ";".join([f"{k}: {self.labelStyle[k]}" for k in self.labelStyle])
|
|
220
|
+
return f"<span style='{style}'>{text}</span>"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _AutoLimitsButton(qtw.QToolButton):
|
|
224
|
+
"""Refresh its prospective-limit tooltip immediately before display."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, refresh_tooltip: Callable[[], None], parent: qtw.QWidget):
|
|
227
|
+
super().__init__(parent)
|
|
228
|
+
self._refresh_tooltip = refresh_tooltip
|
|
229
|
+
|
|
230
|
+
def event(self, event: QtCore.QEvent) -> bool:
|
|
231
|
+
if event.type() == QtCore.QEvent.Type.ToolTip:
|
|
232
|
+
self._refresh_tooltip()
|
|
233
|
+
return super().event(event)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PlotAxisScalingMixin(_PlotAxisScalingBase):
|
|
237
|
+
"""
|
|
238
|
+
Axis scaling dialogs and controls shared by plot windows.
|
|
239
|
+
|
|
240
|
+
This mixin adapts pyqtgraph's embedded ViewBox controls into qPlot dialogs
|
|
241
|
+
opened by double-clicking the plot axes.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def _init_axis_scale_dialogs(self) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Move pyqtgraph's axis scaling controls into one tabbed dialog.
|
|
247
|
+
|
|
248
|
+
"""
|
|
249
|
+
self._axis_scale_controls = {}
|
|
250
|
+
self._axis_scale_dialog = None
|
|
251
|
+
self._axis_scale_tabs = None
|
|
252
|
+
self._axis_scale_custom_auto_axes: set[_AxisName] = set()
|
|
253
|
+
self._axis_scale_programmatic_change_depth = 0
|
|
254
|
+
self._axis_scale_range_connections: list[tuple[Any, Any]] = []
|
|
255
|
+
|
|
256
|
+
for _axis, menu_text in _AXIS_MENU_ITEMS:
|
|
257
|
+
action = self._context_menu_action(menu_text)
|
|
258
|
+
if action is None or action.menu() is None:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
self.vbMenu.removeAction(action)
|
|
262
|
+
|
|
263
|
+
# Log controls now live with the other per-axis controls. The original
|
|
264
|
+
# PlotItem checkboxes are global to both sides and cannot represent X2
|
|
265
|
+
# and Y2 independently.
|
|
266
|
+
plot_controls = getattr(self.plot, "ctrl", None)
|
|
267
|
+
for name in ("logXCheck", "logYCheck"):
|
|
268
|
+
control = getattr(plot_controls, name, None)
|
|
269
|
+
if control is not None:
|
|
270
|
+
control.hide()
|
|
271
|
+
|
|
272
|
+
self._install_axis_scale_viewbox_range_handlers(
|
|
273
|
+
self.vb,
|
|
274
|
+
x_axis="x",
|
|
275
|
+
y_axis="y",
|
|
276
|
+
)
|
|
277
|
+
self._install_axis_scale_double_click_handlers()
|
|
278
|
+
if getattr(self, "operation_kind", None) in ("plot1d", "sweeper"):
|
|
279
|
+
# Full-view requests must reach qPlot before native bounds derived
|
|
280
|
+
# from drawing-clipped coordinates mutate the current view.
|
|
281
|
+
self.plot.autoBtn.clicked.disconnect(self.plot.autoBtnClicked)
|
|
282
|
+
self.plot.autoBtn.clicked.connect(self._axis_scale_full_view_button_clicked)
|
|
283
|
+
self.vb.set_auto_range_handler(lambda: self.force_all_axes_autoscale())
|
|
284
|
+
|
|
285
|
+
def _axis_scale_full_view_button_clicked(self) -> None:
|
|
286
|
+
"""Apply full-view Auto with the same range guard as axis controls."""
|
|
287
|
+
if self.plot.autoBtn.mode == "auto":
|
|
288
|
+
self.force_all_axes_autoscale()
|
|
289
|
+
self.plot.autoBtn.hide()
|
|
290
|
+
self.plot.sigRangeChangedManually.emit(self.vb.mouseEnabled()[:])
|
|
291
|
+
return
|
|
292
|
+
self.__dict__.get("_axis_scale_custom_auto_axes", set()).clear()
|
|
293
|
+
self.plot.disableAutoRange()
|
|
294
|
+
for name in ("right_vb", "top_vb", "top_right_vb"):
|
|
295
|
+
viewbox = self.__dict__.get(name)
|
|
296
|
+
if viewbox is not None:
|
|
297
|
+
viewbox.disableAutoRange()
|
|
298
|
+
for axis in self.__dict__.get("_axis_scale_controls", {}):
|
|
299
|
+
self._sync_axis_scale_controls(axis)
|
|
300
|
+
|
|
301
|
+
def _install_axis_scale_viewbox_range_handlers(
|
|
302
|
+
self,
|
|
303
|
+
viewbox: Any,
|
|
304
|
+
*,
|
|
305
|
+
x_axis: _AxisName | None = None,
|
|
306
|
+
y_axis: _AxisName | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Keep dialog fields synchronized with semantic ViewBox dimensions."""
|
|
309
|
+
|
|
310
|
+
installed = self.__dict__.setdefault(
|
|
311
|
+
"_axis_scale_range_handler_keys",
|
|
312
|
+
set(),
|
|
313
|
+
)
|
|
314
|
+
connections = self.__dict__.setdefault(
|
|
315
|
+
"_axis_scale_range_connections",
|
|
316
|
+
[],
|
|
317
|
+
)
|
|
318
|
+
for signal_name, axis in (
|
|
319
|
+
("sigXRangeChanged", x_axis),
|
|
320
|
+
("sigYRangeChanged", y_axis),
|
|
321
|
+
):
|
|
322
|
+
if axis is None:
|
|
323
|
+
continue
|
|
324
|
+
key = (id(viewbox), axis)
|
|
325
|
+
if key in installed:
|
|
326
|
+
continue
|
|
327
|
+
signal = getattr(viewbox, signal_name, None)
|
|
328
|
+
if signal is None or not callable(getattr(signal, "connect", None)):
|
|
329
|
+
continue
|
|
330
|
+
slot = lambda *_args, axis=axis: self._axis_scale_range_changed(axis)
|
|
331
|
+
signal.connect(slot)
|
|
332
|
+
connections.append((signal, slot))
|
|
333
|
+
installed.add(key)
|
|
334
|
+
|
|
335
|
+
def _axis_scale_range_changed(self, axis: _AxisName) -> None:
|
|
336
|
+
"""Synchronize controls and refresh Auto axes using this visible range."""
|
|
337
|
+
|
|
338
|
+
if self.__dict__.get("_axis_scale_programmatic_change_depth", 0) == 0:
|
|
339
|
+
self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
|
|
340
|
+
if axis in self.__dict__.get("_axis_scale_controls", {}):
|
|
341
|
+
self._sync_axis_scale_controls(axis)
|
|
342
|
+
if (
|
|
343
|
+
self.__dict__.get("_axis_scale_custom_auto_axes")
|
|
344
|
+
and not self.__dict__.get("_axis_scale_visible_refresh_active", False)
|
|
345
|
+
):
|
|
346
|
+
self.__dict__.setdefault("_axis_scale_changed_axes", set()).add(axis)
|
|
347
|
+
timer = self.__dict__.get("_axis_scale_visible_refresh_timer")
|
|
348
|
+
if timer is None:
|
|
349
|
+
timer = QtCore.QTimer(self)
|
|
350
|
+
timer.setSingleShot(True)
|
|
351
|
+
timer.timeout.connect(self._refresh_axis_scale_visible_auto)
|
|
352
|
+
self._axis_scale_visible_refresh_timer = timer
|
|
353
|
+
# Linked axis-pair ViewBoxes may receive the same signal after us.
|
|
354
|
+
# Read their final ranges once the current signal delivery finishes.
|
|
355
|
+
timer.start(0)
|
|
356
|
+
|
|
357
|
+
def _refresh_axis_scale_visible_auto(self) -> None:
|
|
358
|
+
"""Apply one bounded refresh of dependent semantic axes after a change."""
|
|
359
|
+
|
|
360
|
+
changed_axes = self.__dict__.pop("_axis_scale_changed_axes", set())
|
|
361
|
+
limits_by_axis: dict[_AxisName, tuple[float, float]] = {}
|
|
362
|
+
for axis, _label, _side in _AXIS_SPECS:
|
|
363
|
+
if axis not in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
|
|
364
|
+
continue
|
|
365
|
+
number = self._axis_scale_axis_number(axis)
|
|
366
|
+
state = self._axis_scale_viewbox(axis).getState(copy=False)
|
|
367
|
+
if not state["autoVisibleOnly"][number]:
|
|
368
|
+
continue
|
|
369
|
+
for bounds_viewbox, _items in self._axis_scale_bound_item_groups(axis):
|
|
370
|
+
# The containing ViewBox identifies the perpendicular axis of
|
|
371
|
+
# these assigned items, independently of the tab's owner.
|
|
372
|
+
if number == 0:
|
|
373
|
+
perpendicular: _AxisName = (
|
|
374
|
+
"y2" if bounds_viewbox in (
|
|
375
|
+
self.__dict__.get("right_vb"),
|
|
376
|
+
self.__dict__.get("top_right_vb"),
|
|
377
|
+
) else "y"
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
perpendicular = (
|
|
381
|
+
"x2" if bounds_viewbox in (
|
|
382
|
+
self.__dict__.get("top_vb"),
|
|
383
|
+
self.__dict__.get("top_right_vb"),
|
|
384
|
+
) else "x"
|
|
385
|
+
)
|
|
386
|
+
if perpendicular in changed_axes:
|
|
387
|
+
limits = self._axis_scale_auto_limits(axis)
|
|
388
|
+
if limits is not None:
|
|
389
|
+
limits_by_axis[axis] = limits
|
|
390
|
+
break
|
|
391
|
+
|
|
392
|
+
# Calculate every range before applying any of them. Mutually dependent
|
|
393
|
+
# axes then use the same visible region, without chasing each other's
|
|
394
|
+
# padding or auto-pan updates across successive event-loop turns.
|
|
395
|
+
self._axis_scale_visible_refresh_active = True
|
|
396
|
+
try:
|
|
397
|
+
for axis, limits in limits_by_axis.items():
|
|
398
|
+
self._apply_axis_scale_filtered_auto(axis, limits=limits)
|
|
399
|
+
finally:
|
|
400
|
+
self._axis_scale_visible_refresh_active = False
|
|
401
|
+
|
|
402
|
+
def force_all_axes_autoscale(self) -> None:
|
|
403
|
+
"""Return every active plot axis to automatic scaling mode."""
|
|
404
|
+
|
|
405
|
+
for axis, _label, _side in _AXIS_SPECS:
|
|
406
|
+
if not self._axis_scale_axis_is_used(axis):
|
|
407
|
+
continue
|
|
408
|
+
if self._axis_scale_uses_filtered_auto(axis):
|
|
409
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
410
|
+
else:
|
|
411
|
+
self._axis_scale_viewbox(axis).enableAutoRange(
|
|
412
|
+
self._axis_scale_axis_constant(axis),
|
|
413
|
+
True,
|
|
414
|
+
)
|
|
415
|
+
if axis in self.__dict__.get("_axis_scale_controls", {}):
|
|
416
|
+
self._sync_axis_scale_controls(axis)
|
|
417
|
+
|
|
418
|
+
def _menu_control_widget(self, menu: qtw.QMenu) -> qtw.QWidget | None:
|
|
419
|
+
"""
|
|
420
|
+
Returns the embedded control widget from a QWidgetAction menu.
|
|
421
|
+
|
|
422
|
+
"""
|
|
423
|
+
for action in menu.actions():
|
|
424
|
+
if isinstance(action, qtw.QWidgetAction):
|
|
425
|
+
return action.defaultWidget()
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
def _install_axis_scale_double_click_handlers(self) -> None:
|
|
429
|
+
"""
|
|
430
|
+
Open the relevant axis scale dialog when an axis is double-clicked.
|
|
431
|
+
|
|
432
|
+
"""
|
|
433
|
+
for axis, _label, side in _AXIS_SPECS:
|
|
434
|
+
axis_item = self.plot.getAxis(side)
|
|
435
|
+
if axis_item is None:
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
previous_handler = getattr(axis_item, "mouseDoubleClickEvent", None)
|
|
439
|
+
|
|
440
|
+
def mouse_double_click(
|
|
441
|
+
event: Any,
|
|
442
|
+
axis: _AxisName = axis,
|
|
443
|
+
previous_handler: Any = previous_handler,
|
|
444
|
+
) -> None:
|
|
445
|
+
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
|
446
|
+
self.open_axis_scale_dialog(axis)
|
|
447
|
+
event.accept()
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
if previous_handler is not None:
|
|
451
|
+
previous_handler(event)
|
|
452
|
+
|
|
453
|
+
axis_item.mouseDoubleClickEvent = mouse_double_click
|
|
454
|
+
|
|
455
|
+
def _axis_scale_dialog_title(self) -> str:
|
|
456
|
+
return "Axis scaling"
|
|
457
|
+
|
|
458
|
+
def _axis_scale_dimension(self, axis: _AxisName) -> Literal["x", "y"]:
|
|
459
|
+
return _AXIS_DIMENSIONS[axis]
|
|
460
|
+
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _axis_scale_normalise_axis(axis: str) -> _AxisName:
|
|
463
|
+
"""Return the semantic axis represented by a name or physical side."""
|
|
464
|
+
|
|
465
|
+
semantic_axis = _AXIS_ALIASES.get(axis.lower())
|
|
466
|
+
if semantic_axis is None:
|
|
467
|
+
raise ValueError(f"Unknown plot axis: {axis!r}")
|
|
468
|
+
return semantic_axis
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
def _axis_scale_transform_result(
|
|
472
|
+
source: _NumericValue,
|
|
473
|
+
result: npt.NDArray[np.float64],
|
|
474
|
+
) -> float | npt.NDArray[np.float64]:
|
|
475
|
+
"""Keep scalar transforms scalar while supporting array-like inputs."""
|
|
476
|
+
|
|
477
|
+
if np.ndim(source) == 0:
|
|
478
|
+
return float(result)
|
|
479
|
+
return result
|
|
480
|
+
|
|
481
|
+
def data_to_view(
|
|
482
|
+
self,
|
|
483
|
+
axis: str,
|
|
484
|
+
values: _NumericValue,
|
|
485
|
+
) -> float | npt.NDArray[np.float64]:
|
|
486
|
+
"""Convert physical data values to coordinates used by a ViewBox.
|
|
487
|
+
|
|
488
|
+
Linear axes are unchanged. Log axes match ``PlotDataItem``: only
|
|
489
|
+
positive finite samples have display coordinates; nonpositive and
|
|
490
|
+
non-finite raw samples map to NaN and therefore do not participate in
|
|
491
|
+
geometry or hit testing.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
semantic_axis = self._axis_scale_normalise_axis(axis)
|
|
495
|
+
numeric = np.asarray(values, dtype=float)
|
|
496
|
+
if not self._axis_scale_log_mode(semantic_axis):
|
|
497
|
+
result = numeric.copy()
|
|
498
|
+
else:
|
|
499
|
+
result = np.full(numeric.shape, np.nan, dtype=float)
|
|
500
|
+
valid = np.isfinite(numeric) & (numeric > 0)
|
|
501
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
502
|
+
np.log10(numeric, out=result, where=valid)
|
|
503
|
+
return self._axis_scale_transform_result(values, result)
|
|
504
|
+
|
|
505
|
+
def view_to_data(
|
|
506
|
+
self,
|
|
507
|
+
axis: str,
|
|
508
|
+
values: _NumericValue,
|
|
509
|
+
) -> float | npt.NDArray[np.float64]:
|
|
510
|
+
"""Convert ViewBox coordinates to physical data values.
|
|
511
|
+
|
|
512
|
+
For log axes this is the extended-real inverse of ``log10``: NaN
|
|
513
|
+
remains NaN, positive infinity remains infinity, and negative
|
|
514
|
+
infinity maps to zero. Finite overflow deliberately becomes infinity.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
semantic_axis = self._axis_scale_normalise_axis(axis)
|
|
518
|
+
numeric = np.asarray(values, dtype=float)
|
|
519
|
+
if not self._axis_scale_log_mode(semantic_axis):
|
|
520
|
+
result = numeric.copy()
|
|
521
|
+
else:
|
|
522
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
523
|
+
result = np.asarray(np.power(10.0, numeric), dtype=float)
|
|
524
|
+
return self._axis_scale_transform_result(values, result)
|
|
525
|
+
|
|
526
|
+
def _axis_scale_axis_for_line(
|
|
527
|
+
self,
|
|
528
|
+
line: Any,
|
|
529
|
+
dimension: Literal["x", "y"],
|
|
530
|
+
) -> _AxisName:
|
|
531
|
+
"""Return the semantic axis assigned to one plotted line dimension."""
|
|
532
|
+
|
|
533
|
+
styles = self.__dict__.get("_trace_styles")
|
|
534
|
+
lines = self.__dict__.get("lines")
|
|
535
|
+
style = None
|
|
536
|
+
if isinstance(styles, dict) and isinstance(lines, dict):
|
|
537
|
+
trace_key = next(
|
|
538
|
+
(key for key, candidate in lines.items() if candidate is line),
|
|
539
|
+
None,
|
|
540
|
+
)
|
|
541
|
+
if trace_key is not None:
|
|
542
|
+
style = styles.get(trace_key)
|
|
543
|
+
|
|
544
|
+
if dimension == "x":
|
|
545
|
+
return "x2" if getattr(style, "x_axis", "Bottom") == "Top" else "x"
|
|
546
|
+
return "y2" if getattr(style, "y_axis", "Left") == "Right" else "y"
|
|
547
|
+
|
|
548
|
+
def _axis_scale_viewbox(self, axis: _AxisName) -> Any:
|
|
549
|
+
if axis == "x2":
|
|
550
|
+
top_vb = self.__dict__.get("top_vb")
|
|
551
|
+
if top_vb is not None:
|
|
552
|
+
return top_vb
|
|
553
|
+
if axis == "y2":
|
|
554
|
+
right_vb = self.__dict__.get("right_vb")
|
|
555
|
+
if right_vb is not None:
|
|
556
|
+
return right_vb
|
|
557
|
+
return self.vb
|
|
558
|
+
|
|
559
|
+
def _axis_scale_axis_number(self, axis: _AxisName) -> int:
|
|
560
|
+
return 0 if self._axis_scale_dimension(axis) == "x" else 1
|
|
561
|
+
|
|
562
|
+
def _axis_scale_axis_constant(self, axis: _AxisName) -> int:
|
|
563
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
564
|
+
return pg.ViewBox.XAxis
|
|
565
|
+
return pg.ViewBox.YAxis
|
|
566
|
+
|
|
567
|
+
def _new_axis_scale_controls(self, axis: _AxisName) -> qtw.QWidget:
|
|
568
|
+
"""
|
|
569
|
+
Build a fresh copy of pyqtgraph's axis scaling controls for a dialog.
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
widget = qtw.QWidget()
|
|
573
|
+
ui: Any = axisCtrlTemplate_generic.Ui_Form()
|
|
574
|
+
ui.setupUi(widget)
|
|
575
|
+
widget.setFixedWidth(280)
|
|
576
|
+
|
|
577
|
+
ui.mouseCheck.setText("Allow Zoom/Pan")
|
|
578
|
+
ui.mouseCheck.setToolTip(
|
|
579
|
+
"Allow mouse-wheel zooming and drag-panning for this axis."
|
|
580
|
+
)
|
|
581
|
+
ui.invertCheck.setText("Reverse Axis")
|
|
582
|
+
ui.invertCheck.setToolTip(
|
|
583
|
+
"Reverse this axis so values increase in the opposite direction."
|
|
584
|
+
)
|
|
585
|
+
ui.invertCheck.setMinimumWidth(ui.invertCheck.sizeHint().width())
|
|
586
|
+
ui.mouseCheck.setMinimumWidth(ui.mouseCheck.sizeHint().width())
|
|
587
|
+
ui.visibleOnlyCheck.setText("Autoscale from Visible Data")
|
|
588
|
+
ui.visibleOnlyCheck.setToolTip(
|
|
589
|
+
"When autoscaling, use only data visible within the other axis's "
|
|
590
|
+
"current range."
|
|
591
|
+
)
|
|
592
|
+
ui.autoPanCheck.setText("Follow New Data, Keep Span")
|
|
593
|
+
ui.autoPanCheck.setToolTip(
|
|
594
|
+
"Follow new data by moving this axis without changing its displayed span."
|
|
595
|
+
)
|
|
596
|
+
ui.logCheck = qtw.QCheckBox("Log Scale", widget)
|
|
597
|
+
ui.logCheck.setObjectName("axisScaleLogCheck")
|
|
598
|
+
ui.logCheck.setToolTip(
|
|
599
|
+
"Display this axis on a base-10 logarithmic scale. Values at or "
|
|
600
|
+
"below zero are not shown."
|
|
601
|
+
)
|
|
602
|
+
ui.autoPercentSpin.setToolTip(
|
|
603
|
+
"Percentage of the data range included during autoscaling. Lower "
|
|
604
|
+
"values can reduce the influence of extreme spikes."
|
|
605
|
+
)
|
|
606
|
+
ui.linkCombo.setToolTip(
|
|
607
|
+
"Match this axis's displayed range to the corresponding axis in "
|
|
608
|
+
"another linked plot."
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# The upstream form is intentionally very dense and relies on the
|
|
612
|
+
# order of two unlabelled edits to communicate minimum/maximum. Keep
|
|
613
|
+
# its controls and behaviour, but arrange them more clearly for the
|
|
614
|
+
# standalone qPlot dialog.
|
|
615
|
+
while ui.gridLayout.takeAt(0) is not None:
|
|
616
|
+
pass
|
|
617
|
+
ui.gridLayout.setContentsMargins(8, 8, 8, 8)
|
|
618
|
+
ui.gridLayout.setHorizontalSpacing(6)
|
|
619
|
+
ui.gridLayout.setVerticalSpacing(5)
|
|
620
|
+
|
|
621
|
+
ui.minimumLabel = qtw.QLabel("Minimum", widget)
|
|
622
|
+
ui.minimumLabel.setObjectName("axisScaleMinimumLabel")
|
|
623
|
+
ui.maximumLabel = qtw.QLabel("Maximum", widget)
|
|
624
|
+
ui.maximumLabel.setObjectName("axisScaleMaximumLabel")
|
|
625
|
+
ui.minimumLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
|
626
|
+
ui.maximumLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
|
627
|
+
|
|
628
|
+
ui.copyAutoLimitsButton = _AutoLimitsButton(
|
|
629
|
+
lambda axis=axis: self._update_axis_scale_auto_limits_tooltip(axis),
|
|
630
|
+
widget,
|
|
631
|
+
)
|
|
632
|
+
ui.copyAutoLimitsButton.setObjectName("copyAutoLimitsButton")
|
|
633
|
+
ui.copyAutoLimitsButton.setAccessibleName("Use auto limits as manual limits")
|
|
634
|
+
ui.copyAutoLimitsButton.setFixedSize(26, 24)
|
|
635
|
+
style = qtw.QApplication.style()
|
|
636
|
+
if style is not None:
|
|
637
|
+
ui.copyAutoLimitsButton.setIcon(
|
|
638
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_BrowserReload)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
ui.rangeSeparator = qtw.QFrame(widget)
|
|
642
|
+
ui.rangeSeparator.setObjectName("axisScaleRangeSeparator")
|
|
643
|
+
ui.rangeSeparator.setFrameShape(qtw.QFrame.Shape.HLine)
|
|
644
|
+
ui.rangeSeparator.setFrameShadow(qtw.QFrame.Shadow.Sunken)
|
|
645
|
+
ui.linkSeparator = qtw.QFrame(widget)
|
|
646
|
+
ui.linkSeparator.setObjectName("axisScaleLinkSeparator")
|
|
647
|
+
ui.linkSeparator.setFrameShape(qtw.QFrame.Shape.HLine)
|
|
648
|
+
ui.linkSeparator.setFrameShadow(qtw.QFrame.Shadow.Sunken)
|
|
649
|
+
|
|
650
|
+
ui.gridLayout.addWidget(ui.minimumLabel, 0, 1)
|
|
651
|
+
ui.gridLayout.addWidget(ui.maximumLabel, 0, 2)
|
|
652
|
+
ui.gridLayout.addWidget(ui.manualRadio, 1, 0)
|
|
653
|
+
ui.gridLayout.addWidget(ui.minText, 1, 1)
|
|
654
|
+
ui.gridLayout.addWidget(ui.maxText, 1, 2)
|
|
655
|
+
ui.gridLayout.addWidget(ui.copyAutoLimitsButton, 1, 3)
|
|
656
|
+
ui.gridLayout.addWidget(ui.autoRadio, 2, 0)
|
|
657
|
+
ui.gridLayout.addWidget(ui.autoPercentSpin, 2, 1, 1, 2)
|
|
658
|
+
ui.gridLayout.addWidget(ui.visibleOnlyCheck, 3, 1, 1, 3)
|
|
659
|
+
ui.gridLayout.addWidget(ui.autoPanCheck, 4, 1, 1, 3)
|
|
660
|
+
ui.gridLayout.addWidget(ui.rangeSeparator, 5, 0, 1, 4)
|
|
661
|
+
ui.gridLayout.addWidget(ui.logCheck, 6, 0, 1, 4)
|
|
662
|
+
ui.gridLayout.addWidget(ui.invertCheck, 7, 0, 1, 2)
|
|
663
|
+
ui.gridLayout.addWidget(ui.mouseCheck, 7, 2, 1, 2)
|
|
664
|
+
ui.gridLayout.addWidget(ui.linkSeparator, 8, 0, 1, 4)
|
|
665
|
+
ui.gridLayout.addWidget(ui.label, 9, 0)
|
|
666
|
+
ui.gridLayout.addWidget(ui.linkCombo, 9, 1, 1, 3)
|
|
667
|
+
ui.gridLayout.setColumnStretch(1, 1)
|
|
668
|
+
ui.gridLayout.setColumnStretch(2, 1)
|
|
669
|
+
self._axis_scale_controls[axis] = ui
|
|
670
|
+
|
|
671
|
+
ui.mouseCheck.toggled.connect(
|
|
672
|
+
lambda checked, axis=axis: self._axis_scale_mouse_toggled(axis, checked)
|
|
673
|
+
)
|
|
674
|
+
ui.manualRadio.clicked.connect(
|
|
675
|
+
lambda _checked=False, axis=axis: self._axis_scale_manual_clicked(axis)
|
|
676
|
+
)
|
|
677
|
+
ui.minText.editingFinished.connect(
|
|
678
|
+
lambda axis=axis: self._axis_scale_range_text_changed(axis)
|
|
679
|
+
)
|
|
680
|
+
ui.maxText.editingFinished.connect(
|
|
681
|
+
lambda axis=axis: self._axis_scale_range_text_changed(axis)
|
|
682
|
+
)
|
|
683
|
+
ui.autoRadio.clicked.connect(
|
|
684
|
+
lambda _checked=False, axis=axis: self._axis_scale_auto_clicked(axis)
|
|
685
|
+
)
|
|
686
|
+
ui.autoPercentSpin.valueChanged.connect(
|
|
687
|
+
lambda value, axis=axis: self._axis_scale_auto_spin_changed(axis, value)
|
|
688
|
+
)
|
|
689
|
+
ui.linkCombo.currentIndexChanged.connect(
|
|
690
|
+
lambda _index, axis=axis: self._axis_scale_link_changed(axis)
|
|
691
|
+
)
|
|
692
|
+
ui.autoPanCheck.toggled.connect(
|
|
693
|
+
lambda checked, axis=axis: self._axis_scale_auto_pan_toggled(axis, checked)
|
|
694
|
+
)
|
|
695
|
+
ui.visibleOnlyCheck.toggled.connect(
|
|
696
|
+
lambda checked, axis=axis: self._axis_scale_visible_only_toggled(axis, checked)
|
|
697
|
+
)
|
|
698
|
+
ui.invertCheck.toggled.connect(
|
|
699
|
+
lambda checked, axis=axis: self._axis_scale_invert_toggled(axis, checked)
|
|
700
|
+
)
|
|
701
|
+
ui.logCheck.toggled.connect(
|
|
702
|
+
lambda checked, axis=axis: self._axis_scale_log_toggled(axis, checked)
|
|
703
|
+
)
|
|
704
|
+
ui.copyAutoLimitsButton.clicked.connect(
|
|
705
|
+
lambda _checked=False, axis=axis: self._axis_scale_copy_auto_limits(axis)
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
return widget
|
|
709
|
+
|
|
710
|
+
def _sync_axis_scale_controls(self, axis: _AxisName) -> None:
|
|
711
|
+
"""
|
|
712
|
+
Update a dialog's controls from the current view state.
|
|
713
|
+
|
|
714
|
+
"""
|
|
715
|
+
ui = self._axis_scale_controls.get(axis)
|
|
716
|
+
if ui is None:
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
720
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
721
|
+
state = viewbox.getState(copy=False)
|
|
722
|
+
|
|
723
|
+
for widget in (
|
|
724
|
+
ui.minText,
|
|
725
|
+
ui.maxText,
|
|
726
|
+
ui.manualRadio,
|
|
727
|
+
ui.autoRadio,
|
|
728
|
+
ui.autoPercentSpin,
|
|
729
|
+
ui.linkCombo,
|
|
730
|
+
ui.autoPanCheck,
|
|
731
|
+
ui.visibleOnlyCheck,
|
|
732
|
+
ui.invertCheck,
|
|
733
|
+
ui.logCheck,
|
|
734
|
+
ui.mouseCheck,
|
|
735
|
+
):
|
|
736
|
+
widget.blockSignals(True)
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
target_range = state["targetRange"][axis_number]
|
|
740
|
+
data_range = self.view_to_data(axis, target_range)
|
|
741
|
+
# Editable limits must round-trip doubles, even for a narrow range
|
|
742
|
+
# at a large offset. Tick-label precision is insufficient here.
|
|
743
|
+
ui.minText.setText(f"{data_range[0]:.17g}")
|
|
744
|
+
ui.maxText.setText(f"{data_range[1]:.17g}")
|
|
745
|
+
|
|
746
|
+
auto_range = (
|
|
747
|
+
True
|
|
748
|
+
if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set())
|
|
749
|
+
else state["autoRange"][axis_number]
|
|
750
|
+
)
|
|
751
|
+
ui.autoRadio.setChecked(auto_range is not False)
|
|
752
|
+
ui.manualRadio.setChecked(auto_range is False)
|
|
753
|
+
if auto_range is not False and auto_range is not True:
|
|
754
|
+
ui.autoPercentSpin.setValue(int(auto_range * 100))
|
|
755
|
+
|
|
756
|
+
ui.mouseCheck.setChecked(state["mouseEnabled"][axis_number])
|
|
757
|
+
ui.autoPanCheck.setChecked(state["autoPan"][axis_number])
|
|
758
|
+
ui.visibleOnlyCheck.setChecked(state["autoVisibleOnly"][axis_number])
|
|
759
|
+
dimension = self._axis_scale_dimension(axis)
|
|
760
|
+
ui.invertCheck.setChecked(state.get(dimension + "Inverted", False))
|
|
761
|
+
axis_item = self.plot.getAxis(_AXIS_SIDES[axis])
|
|
762
|
+
ui.logCheck.setChecked(bool(getattr(axis_item, "logMode", False)))
|
|
763
|
+
log_supported = self._axis_scale_log_is_supported(axis)
|
|
764
|
+
ui.logCheck.setEnabled(log_supported)
|
|
765
|
+
if log_supported:
|
|
766
|
+
ui.logCheck.setToolTip(
|
|
767
|
+
"Display this axis on a base-10 logarithmic scale. Values "
|
|
768
|
+
"at or below zero are not shown."
|
|
769
|
+
)
|
|
770
|
+
else:
|
|
771
|
+
ui.logCheck.setToolTip(
|
|
772
|
+
"Log scaling is available for line-plot axes."
|
|
773
|
+
)
|
|
774
|
+
self._sync_axis_scale_link_combo(axis)
|
|
775
|
+
self._update_axis_scale_auto_limits_tooltip(axis)
|
|
776
|
+
finally:
|
|
777
|
+
for widget in (
|
|
778
|
+
ui.minText,
|
|
779
|
+
ui.maxText,
|
|
780
|
+
ui.manualRadio,
|
|
781
|
+
ui.autoRadio,
|
|
782
|
+
ui.autoPercentSpin,
|
|
783
|
+
ui.linkCombo,
|
|
784
|
+
ui.autoPanCheck,
|
|
785
|
+
ui.visibleOnlyCheck,
|
|
786
|
+
ui.invertCheck,
|
|
787
|
+
ui.logCheck,
|
|
788
|
+
ui.mouseCheck,
|
|
789
|
+
):
|
|
790
|
+
widget.blockSignals(False)
|
|
791
|
+
|
|
792
|
+
def _sync_axis_scale_link_combo(self, axis: _AxisName) -> None:
|
|
793
|
+
"""
|
|
794
|
+
Mirror pyqtgraph's available linked views into the dialog link combo.
|
|
795
|
+
|
|
796
|
+
"""
|
|
797
|
+
ui = self._axis_scale_controls[axis]
|
|
798
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
799
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
800
|
+
menu = self.vbMenu if viewbox is self.vb else getattr(viewbox, "menu", None)
|
|
801
|
+
source_combo = getattr(menu, "ctrl", [None, None])[axis_number]
|
|
802
|
+
source_combo = getattr(source_combo, "linkCombo", None)
|
|
803
|
+
current = viewbox.getState(copy=False)["linkedViews"][axis_number] or ""
|
|
804
|
+
|
|
805
|
+
ui.linkCombo.clear()
|
|
806
|
+
if source_combo is None:
|
|
807
|
+
return
|
|
808
|
+
for index in range(source_combo.count()):
|
|
809
|
+
ui.linkCombo.addItem(source_combo.itemText(index))
|
|
810
|
+
|
|
811
|
+
index = ui.linkCombo.findText(current)
|
|
812
|
+
ui.linkCombo.setCurrentIndex(max(index, 0))
|
|
813
|
+
|
|
814
|
+
def _axis_scale_bound_item_groups(
|
|
815
|
+
self,
|
|
816
|
+
axis: _AxisName,
|
|
817
|
+
) -> list[tuple[Any, list[Any] | None]]:
|
|
818
|
+
"""Group plot items assigned to an axis by their containing ViewBox."""
|
|
819
|
+
|
|
820
|
+
styles = self.__dict__.get("_trace_styles")
|
|
821
|
+
lines = self.__dict__.get("lines")
|
|
822
|
+
if not isinstance(styles, dict) or not isinstance(lines, dict):
|
|
823
|
+
assignments = self.__dict__.get("_heatmap_axis_assignments")
|
|
824
|
+
heatmaps = self.__dict__.get("heatmaps")
|
|
825
|
+
axis_sides = getattr(self, "_heatmap_axis_sides", None)
|
|
826
|
+
render_items = getattr(self, "_heatmap_render_items", None)
|
|
827
|
+
axis_viewbox = getattr(self, "_heatmap_axis_viewbox", None)
|
|
828
|
+
if (
|
|
829
|
+
isinstance(assignments, dict)
|
|
830
|
+
and isinstance(heatmaps, dict)
|
|
831
|
+
and callable(axis_sides)
|
|
832
|
+
and callable(render_items)
|
|
833
|
+
and callable(axis_viewbox)
|
|
834
|
+
):
|
|
835
|
+
dimension = self._axis_scale_dimension(axis)
|
|
836
|
+
expected = {
|
|
837
|
+
"x": "Bottom",
|
|
838
|
+
"x2": "Top",
|
|
839
|
+
"y": "Left",
|
|
840
|
+
"y2": "Right",
|
|
841
|
+
}[axis]
|
|
842
|
+
heatmap_groups: dict[Any, list[Any]] = {}
|
|
843
|
+
for layer in heatmaps.values():
|
|
844
|
+
x_side, y_side = axis_sides(layer)
|
|
845
|
+
selected = x_side if dimension == "x" else y_side
|
|
846
|
+
if selected != expected:
|
|
847
|
+
continue
|
|
848
|
+
viewbox = axis_viewbox(layer)
|
|
849
|
+
heatmap_groups.setdefault(viewbox, []).extend(
|
|
850
|
+
render_items(layer)
|
|
851
|
+
)
|
|
852
|
+
return list(heatmap_groups.items())
|
|
853
|
+
return [(self._axis_scale_viewbox(axis), None)]
|
|
854
|
+
|
|
855
|
+
attribute, value = {
|
|
856
|
+
"x": ("x_axis", "Bottom"),
|
|
857
|
+
"y": ("y_axis", "Left"),
|
|
858
|
+
"x2": ("x_axis", "Top"),
|
|
859
|
+
"y2": ("y_axis", "Right"),
|
|
860
|
+
}[axis]
|
|
861
|
+
grouped: dict[Any, list[Any]] = {}
|
|
862
|
+
for trace_key, line in lines.items():
|
|
863
|
+
style = styles.get(trace_key)
|
|
864
|
+
if (
|
|
865
|
+
line is None
|
|
866
|
+
or getattr(style, attribute, None) != value
|
|
867
|
+
):
|
|
868
|
+
continue
|
|
869
|
+
get_viewbox = getattr(line, "getViewBox", None)
|
|
870
|
+
viewbox = get_viewbox() if callable(get_viewbox) else None
|
|
871
|
+
if viewbox is None:
|
|
872
|
+
uses_top = getattr(style, "x_axis", "Bottom") == "Top"
|
|
873
|
+
uses_right = getattr(style, "y_axis", "Left") == "Right"
|
|
874
|
+
if uses_top and uses_right:
|
|
875
|
+
viewbox = self.__dict__.get("top_right_vb")
|
|
876
|
+
elif uses_top:
|
|
877
|
+
viewbox = self.__dict__.get("top_vb")
|
|
878
|
+
elif uses_right:
|
|
879
|
+
viewbox = self.__dict__.get("right_vb")
|
|
880
|
+
viewbox = viewbox or self.vb
|
|
881
|
+
grouped.setdefault(viewbox, []).append(line)
|
|
882
|
+
return list(grouped.items())
|
|
883
|
+
|
|
884
|
+
def _axis_scale_auto_limits(self, axis: _AxisName) -> tuple[float, float] | None:
|
|
885
|
+
"""Calculate this tab's auto range without changing the viewbox."""
|
|
886
|
+
|
|
887
|
+
ui = self.__dict__.get("_axis_scale_controls", {}).get(axis)
|
|
888
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
889
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
890
|
+
state = viewbox.getState(copy=False)
|
|
891
|
+
current_ranges = viewbox.viewRange()
|
|
892
|
+
fractions = [1.0, 1.0]
|
|
893
|
+
auto_range = state["autoRange"][axis_number]
|
|
894
|
+
default_fraction = (
|
|
895
|
+
float(auto_range)
|
|
896
|
+
if isinstance(auto_range, (int, float))
|
|
897
|
+
and not isinstance(auto_range, bool)
|
|
898
|
+
else 1.0
|
|
899
|
+
)
|
|
900
|
+
fractions[axis_number] = (
|
|
901
|
+
ui.autoPercentSpin.value() * 0.01
|
|
902
|
+
if ui is not None
|
|
903
|
+
else default_fraction
|
|
904
|
+
)
|
|
905
|
+
visible_only = state["autoVisibleOnly"][axis_number]
|
|
906
|
+
auto_pan = state["autoPan"][axis_number]
|
|
907
|
+
ranges: list[list[float]] = []
|
|
908
|
+
for bounds_viewbox, items in self._axis_scale_bound_item_groups(axis):
|
|
909
|
+
orthogonal_ranges: list[list[float] | None] = [None, None]
|
|
910
|
+
if visible_only:
|
|
911
|
+
orthogonal_ranges[axis_number] = bounds_viewbox.viewRange()[
|
|
912
|
+
1 - axis_number
|
|
913
|
+
]
|
|
914
|
+
with _axis_bounds_without_dynamic_clipping(bounds_viewbox, items):
|
|
915
|
+
for item in items if items is not None else bounds_viewbox.addedItems:
|
|
916
|
+
if not isinstance(item, pg.PlotDataItem) or not item.isVisible():
|
|
917
|
+
continue
|
|
918
|
+
sample_bounds = item.dataBounds(
|
|
919
|
+
axis_number, fractions[axis_number], orthogonal_ranges[axis_number],
|
|
920
|
+
)
|
|
921
|
+
if sample_bounds[0] is not None and sample_bounds[1] is not None:
|
|
922
|
+
sample_lower, sample_upper = (float(value) for value in sample_bounds)
|
|
923
|
+
if (
|
|
924
|
+
isfinite(sample_lower) and isfinite(sample_upper)
|
|
925
|
+
and not all(isfinite(value) for value in (
|
|
926
|
+
sample_upper - sample_lower, sample_upper + sample_lower,
|
|
927
|
+
))
|
|
928
|
+
):
|
|
929
|
+
# Qt rectangles cannot represent this finite pair;
|
|
930
|
+
# preserve it for explicit rejection by the guard.
|
|
931
|
+
return sample_lower, sample_upper
|
|
932
|
+
bounds = bounds_viewbox.childrenBounds(
|
|
933
|
+
frac=fractions,
|
|
934
|
+
orthoRange=orthogonal_ranges,
|
|
935
|
+
items=items,
|
|
936
|
+
)[axis_number]
|
|
937
|
+
if bounds is not None:
|
|
938
|
+
if not all(isfinite(value) for value in bounds):
|
|
939
|
+
# Native pixel padding can overflow finite sample bounds.
|
|
940
|
+
# Keep unsupported geometry distinct from absent data so
|
|
941
|
+
# the common preflight disables retries and reports it.
|
|
942
|
+
return float(bounds[0]), float(bounds[1])
|
|
943
|
+
ranges.append(bounds)
|
|
944
|
+
if not ranges:
|
|
945
|
+
return None
|
|
946
|
+
|
|
947
|
+
lower = min(float(bounds[0]) for bounds in ranges)
|
|
948
|
+
upper = max(float(bounds[1]) for bounds in ranges)
|
|
949
|
+
current_span = current_ranges[axis_number][1] - current_ranges[axis_number][0]
|
|
950
|
+
if auto_pan or lower == upper:
|
|
951
|
+
if not isfinite(current_span) or current_span <= 0:
|
|
952
|
+
current_span = 1.0
|
|
953
|
+
center = lower * 0.5 + upper * 0.5
|
|
954
|
+
# A narrow span at a large offset may collapse to one double.
|
|
955
|
+
# Retain at least the backend's quantization allowance so that
|
|
956
|
+
# unsupported constant/auto-pan geometry reaches our preflight.
|
|
957
|
+
half_span = max(current_span * 0.5, abs(center) * 1.5e-15)
|
|
958
|
+
lower = center - half_span
|
|
959
|
+
upper = center + half_span
|
|
960
|
+
else:
|
|
961
|
+
padding = viewbox.suggestPadding(axis_number)
|
|
962
|
+
extra = (upper - lower) * padding
|
|
963
|
+
lower -= extra
|
|
964
|
+
upper += extra
|
|
965
|
+
|
|
966
|
+
# Existing bounds can produce unsupported geometry after padding or
|
|
967
|
+
# centering. Preserve that result for the common range preflight;
|
|
968
|
+
# None means no bounds, and would bypass rejection and mode recovery.
|
|
969
|
+
return lower, upper
|
|
970
|
+
|
|
971
|
+
def _update_axis_scale_auto_limits_tooltip(self, axis: _AxisName) -> None:
|
|
972
|
+
"""Describe the range that the copy-auto button would apply."""
|
|
973
|
+
|
|
974
|
+
button = self._axis_scale_controls[axis].copyAutoLimitsButton
|
|
975
|
+
limits = self._axis_scale_auto_limits(axis)
|
|
976
|
+
data_limits = (
|
|
977
|
+
None
|
|
978
|
+
if limits is None
|
|
979
|
+
else tuple(float(value) for value in self.view_to_data(axis, limits))
|
|
980
|
+
)
|
|
981
|
+
usable = (
|
|
982
|
+
data_limits is not None
|
|
983
|
+
and all(isfinite(value) for value in data_limits)
|
|
984
|
+
and data_limits[0] < data_limits[1]
|
|
985
|
+
)
|
|
986
|
+
button.setEnabled(usable)
|
|
987
|
+
if not usable:
|
|
988
|
+
button.setToolTip("Auto limits are unavailable for this axis.")
|
|
989
|
+
return
|
|
990
|
+
assert data_limits is not None
|
|
991
|
+
button.setToolTip(
|
|
992
|
+
f"Set manual limits to {data_limits[0]:.17g} and {data_limits[1]:.17g}."
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
def _axis_scale_copy_auto_limits(self, axis: _AxisName) -> None:
|
|
996
|
+
"""Freeze the prospective auto range as this axis's manual range."""
|
|
997
|
+
|
|
998
|
+
limits = self._axis_scale_auto_limits(axis)
|
|
999
|
+
if limits is None:
|
|
1000
|
+
return
|
|
1001
|
+
data_limits = tuple(
|
|
1002
|
+
float(value) for value in self.view_to_data(axis, limits)
|
|
1003
|
+
)
|
|
1004
|
+
if (
|
|
1005
|
+
not all(isfinite(value) for value in data_limits)
|
|
1006
|
+
or data_limits[0] >= data_limits[1]
|
|
1007
|
+
):
|
|
1008
|
+
self._update_axis_scale_auto_limits_tooltip(axis)
|
|
1009
|
+
return
|
|
1010
|
+
# Auto limits already use ViewBox coordinates. Do not pass them through
|
|
1011
|
+
# editable text or a log -> physical -> log conversion before applying.
|
|
1012
|
+
self._apply_axis_scale_manual_limits(axis, limits)
|
|
1013
|
+
self._sync_axis_scale_controls(axis)
|
|
1014
|
+
|
|
1015
|
+
def _axis_scale_mouse_toggled(self, axis: _AxisName, checked: bool) -> None:
|
|
1016
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1017
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1018
|
+
viewbox.setMouseEnabled(x=checked)
|
|
1019
|
+
else:
|
|
1020
|
+
viewbox.setMouseEnabled(y=checked)
|
|
1021
|
+
|
|
1022
|
+
def _axis_scale_manual_clicked(self, axis: _AxisName) -> None:
|
|
1023
|
+
self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
|
|
1024
|
+
self._axis_scale_viewbox(axis).enableAutoRange(
|
|
1025
|
+
self._axis_scale_axis_constant(axis),
|
|
1026
|
+
False,
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
def _axis_scale_range_text_changed(self, axis: _AxisName) -> None:
|
|
1030
|
+
ui = self._axis_scale_controls[axis]
|
|
1031
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
1032
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1033
|
+
previous_view_values = list(viewbox.viewRange()[axis_number])
|
|
1034
|
+
previous_values = np.asarray(self.view_to_data(axis, previous_view_values))
|
|
1035
|
+
try:
|
|
1036
|
+
values = [float(ui.minText.text()), float(ui.maxText.text())]
|
|
1037
|
+
except ValueError:
|
|
1038
|
+
values = []
|
|
1039
|
+
|
|
1040
|
+
if (
|
|
1041
|
+
len(values) != 2
|
|
1042
|
+
or not all(isfinite(value) for value in values)
|
|
1043
|
+
or values[0] >= values[1]
|
|
1044
|
+
):
|
|
1045
|
+
ui.minText.setText(f"{previous_values[0]:.17g}")
|
|
1046
|
+
ui.maxText.setText(f"{previous_values[1]:.17g}")
|
|
1047
|
+
show_status = getattr(self, "show_status", None)
|
|
1048
|
+
if callable(show_status):
|
|
1049
|
+
show_status(
|
|
1050
|
+
"Axis limits must be finite numbers with minimum below maximum.",
|
|
1051
|
+
5000,
|
|
1052
|
+
)
|
|
1053
|
+
return
|
|
1054
|
+
|
|
1055
|
+
if self._axis_scale_log_mode(axis) and any(value <= 0 for value in values):
|
|
1056
|
+
ui.minText.setText(f"{previous_values[0]:.17g}")
|
|
1057
|
+
ui.maxText.setText(f"{previous_values[1]:.17g}")
|
|
1058
|
+
show_status = getattr(self, "show_status", None)
|
|
1059
|
+
if callable(show_status):
|
|
1060
|
+
show_status(
|
|
1061
|
+
"Log-scale axis limits must be greater than zero.",
|
|
1062
|
+
5000,
|
|
1063
|
+
)
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
# Return may emit editingFinished twice. After a rejected edit restores
|
|
1067
|
+
# these fields, the second emission must not switch Auto to Manual.
|
|
1068
|
+
# Unchanged fields also retain their exact original log coordinates.
|
|
1069
|
+
if values == list(previous_values):
|
|
1070
|
+
return
|
|
1071
|
+
view_values = [float(value) for value in self.data_to_view(axis, values)]
|
|
1072
|
+
if not _manual_axis_view_limits_are_usable(viewbox, axis_number, view_values):
|
|
1073
|
+
ui.minText.setText(f"{previous_values[0]:.17g}")
|
|
1074
|
+
ui.maxText.setText(f"{previous_values[1]:.17g}")
|
|
1075
|
+
show_status = getattr(self, "show_status", None)
|
|
1076
|
+
if callable(show_status):
|
|
1077
|
+
show_status("These axis limits exceed the supported plot range.", 5000)
|
|
1078
|
+
return
|
|
1079
|
+
self._apply_axis_scale_manual_limits(axis, view_values)
|
|
1080
|
+
|
|
1081
|
+
def _apply_axis_scale_manual_limits(
|
|
1082
|
+
self,
|
|
1083
|
+
axis: _AxisName,
|
|
1084
|
+
view_values: tuple[float, float] | list[float],
|
|
1085
|
+
) -> None:
|
|
1086
|
+
"""Apply validated numeric ViewBox limits without parsing UI text."""
|
|
1087
|
+
|
|
1088
|
+
ui = self._axis_scale_controls[axis]
|
|
1089
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1090
|
+
if not _manual_axis_view_limits_are_usable(
|
|
1091
|
+
viewbox, self._axis_scale_axis_number(axis), view_values,
|
|
1092
|
+
):
|
|
1093
|
+
self._sync_axis_scale_controls(axis)
|
|
1094
|
+
show_status = getattr(self, "show_status", None)
|
|
1095
|
+
if callable(show_status):
|
|
1096
|
+
show_status("These axis limits exceed the supported plot range.", 5000)
|
|
1097
|
+
return
|
|
1098
|
+
ui.manualRadio.setChecked(True)
|
|
1099
|
+
self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
|
|
1100
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1101
|
+
viewbox.setXRange(*view_values, padding=0)
|
|
1102
|
+
else:
|
|
1103
|
+
viewbox.setYRange(*view_values, padding=0)
|
|
1104
|
+
self._view_range_changed_programmatically()
|
|
1105
|
+
|
|
1106
|
+
def _axis_scale_uses_filtered_auto(self, axis: _AxisName) -> bool:
|
|
1107
|
+
"""Return whether auto-ranging must respect per-item axis assignment."""
|
|
1108
|
+
|
|
1109
|
+
return (
|
|
1110
|
+
getattr(self, "operation_kind", None) == "sweeper"
|
|
1111
|
+
or (
|
|
1112
|
+
isinstance(self.__dict__.get("_trace_styles"), dict)
|
|
1113
|
+
and isinstance(self.__dict__.get("lines"), dict)
|
|
1114
|
+
)
|
|
1115
|
+
or (
|
|
1116
|
+
isinstance(
|
|
1117
|
+
self.__dict__.get("_heatmap_axis_assignments"),
|
|
1118
|
+
dict,
|
|
1119
|
+
)
|
|
1120
|
+
and isinstance(self.__dict__.get("heatmaps"), dict)
|
|
1121
|
+
)
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
def _apply_axis_scale_filtered_auto(
|
|
1125
|
+
self,
|
|
1126
|
+
axis: _AxisName,
|
|
1127
|
+
*,
|
|
1128
|
+
limits: tuple[float, float] | None = None,
|
|
1129
|
+
) -> None:
|
|
1130
|
+
"""Apply an item-filtered auto range while retaining Auto mode in the UI."""
|
|
1131
|
+
|
|
1132
|
+
if limits is None:
|
|
1133
|
+
limits = self._axis_scale_auto_limits(axis)
|
|
1134
|
+
if limits is None:
|
|
1135
|
+
return
|
|
1136
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1137
|
+
axis_constant = self._axis_scale_axis_constant(axis)
|
|
1138
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
1139
|
+
bound_groups = self._axis_scale_bound_item_groups(axis)
|
|
1140
|
+
if not _manual_axis_view_limits_are_usable(viewbox, axis_number, limits):
|
|
1141
|
+
if viewbox.autoRangeEnabled()[axis_number] is not False or any(
|
|
1142
|
+
bounds_viewbox.autoRangeEnabled()[axis_number] is not False
|
|
1143
|
+
for bounds_viewbox, _items in bound_groups
|
|
1144
|
+
):
|
|
1145
|
+
# Initial/native Auto must not retry the rejected geometry on
|
|
1146
|
+
# paint. Retain qPlot's Auto mode so a later supported source
|
|
1147
|
+
# update can recover automatically with the previous view intact.
|
|
1148
|
+
for bounds_viewbox, _items in bound_groups:
|
|
1149
|
+
_disable_native_axis_auto(bounds_viewbox, axis_constant)
|
|
1150
|
+
if all(bounds_viewbox is not viewbox for bounds_viewbox, _items in bound_groups):
|
|
1151
|
+
_disable_native_axis_auto(viewbox, axis_constant)
|
|
1152
|
+
self.__dict__.setdefault("_axis_scale_custom_auto_axes", set()).add(axis)
|
|
1153
|
+
if axis in self.__dict__.get("_axis_scale_controls", {}):
|
|
1154
|
+
self._sync_axis_scale_controls(axis)
|
|
1155
|
+
show_status = getattr(self, "show_status", None)
|
|
1156
|
+
if callable(show_status):
|
|
1157
|
+
show_status("Automatic axis limits exceed the supported plot range.", 5000)
|
|
1158
|
+
return
|
|
1159
|
+
self._axis_scale_programmatic_change_depth = (
|
|
1160
|
+
self.__dict__.get("_axis_scale_programmatic_change_depth", 0) + 1
|
|
1161
|
+
)
|
|
1162
|
+
try:
|
|
1163
|
+
for bounds_viewbox, _items in bound_groups:
|
|
1164
|
+
_disable_native_axis_auto(bounds_viewbox, axis_constant)
|
|
1165
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1166
|
+
viewbox.setXRange(*limits, padding=0)
|
|
1167
|
+
else:
|
|
1168
|
+
viewbox.setYRange(*limits, padding=0)
|
|
1169
|
+
finally:
|
|
1170
|
+
self._axis_scale_programmatic_change_depth -= 1
|
|
1171
|
+
self.__dict__.setdefault("_axis_scale_custom_auto_axes", set()).add(axis)
|
|
1172
|
+
ui = self.__dict__.get("_axis_scale_controls", {}).get(axis)
|
|
1173
|
+
if ui is not None:
|
|
1174
|
+
ui.autoRadio.setChecked(True)
|
|
1175
|
+
self._view_range_changed_programmatically()
|
|
1176
|
+
|
|
1177
|
+
def _install_axis_scale_trace_handler(self, line: Any) -> None:
|
|
1178
|
+
"""Keep filtered auto ranges current when a trace publishes new data."""
|
|
1179
|
+
|
|
1180
|
+
if line is None or getattr(line, "_qplot_axis_scale_handler", False):
|
|
1181
|
+
return
|
|
1182
|
+
signal = getattr(line, "sigPlotChanged", None)
|
|
1183
|
+
if signal is None or not callable(getattr(signal, "connect", None)):
|
|
1184
|
+
return
|
|
1185
|
+
signal.connect(self._axis_scale_trace_data_changed)
|
|
1186
|
+
line._qplot_axis_scale_handler = True
|
|
1187
|
+
|
|
1188
|
+
def _axis_scale_trace_data_changed(self, _line: Any = None) -> None:
|
|
1189
|
+
"""Reapply active trace-filtered auto ranges after a data update."""
|
|
1190
|
+
|
|
1191
|
+
axes = set(self.__dict__.get("_axis_scale_custom_auto_axes", set()))
|
|
1192
|
+
for axis, _label, _side in _AXIS_SPECS:
|
|
1193
|
+
if (self._axis_scale_axis_is_used(axis)
|
|
1194
|
+
and self._axis_scale_uses_filtered_auto(axis)
|
|
1195
|
+
and self._axis_scale_viewbox(axis).autoRangeEnabled()[
|
|
1196
|
+
self._axis_scale_axis_number(axis)] is not False):
|
|
1197
|
+
axes.add(axis)
|
|
1198
|
+
for axis in tuple(axes):
|
|
1199
|
+
if self._axis_scale_axis_is_used(axis):
|
|
1200
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1201
|
+
|
|
1202
|
+
def _axis_scale_auto_clicked(self, axis: _AxisName) -> None:
|
|
1203
|
+
ui = self._axis_scale_controls[axis]
|
|
1204
|
+
if self._axis_scale_uses_filtered_auto(axis):
|
|
1205
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1206
|
+
return
|
|
1207
|
+
self._axis_scale_viewbox(axis).enableAutoRange(
|
|
1208
|
+
self._axis_scale_axis_constant(axis),
|
|
1209
|
+
ui.autoPercentSpin.value() * 0.01,
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
def _axis_scale_auto_spin_changed(self, axis: _AxisName, value: float) -> None:
|
|
1213
|
+
ui = self._axis_scale_controls[axis]
|
|
1214
|
+
ui.autoRadio.setChecked(True)
|
|
1215
|
+
if self._axis_scale_uses_filtered_auto(axis):
|
|
1216
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1217
|
+
return
|
|
1218
|
+
self._axis_scale_viewbox(axis).enableAutoRange(
|
|
1219
|
+
self._axis_scale_axis_constant(axis),
|
|
1220
|
+
value * 0.01,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
def _axis_scale_link_changed(self, axis: _AxisName) -> None:
|
|
1224
|
+
ui = self._axis_scale_controls[axis]
|
|
1225
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1226
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1227
|
+
viewbox.setXLink(str(ui.linkCombo.currentText()))
|
|
1228
|
+
else:
|
|
1229
|
+
viewbox.setYLink(str(ui.linkCombo.currentText()))
|
|
1230
|
+
|
|
1231
|
+
def _axis_scale_auto_pan_toggled(self, axis: _AxisName, checked: bool) -> None:
|
|
1232
|
+
# Store the option on its semantic axis even when qPlot, rather than
|
|
1233
|
+
# native autoRange, owns the range calculation and refreshes.
|
|
1234
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1235
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1236
|
+
viewbox.setAutoPan(x=checked)
|
|
1237
|
+
else:
|
|
1238
|
+
viewbox.setAutoPan(y=checked)
|
|
1239
|
+
if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
|
|
1240
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1241
|
+
|
|
1242
|
+
def _axis_scale_visible_only_toggled(self, axis: _AxisName, checked: bool) -> None:
|
|
1243
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1244
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1245
|
+
viewbox.setAutoVisible(x=checked)
|
|
1246
|
+
else:
|
|
1247
|
+
viewbox.setAutoVisible(y=checked)
|
|
1248
|
+
if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
|
|
1249
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1250
|
+
|
|
1251
|
+
def _axis_scale_invert_toggled(self, axis: _AxisName, checked: bool) -> None:
|
|
1252
|
+
viewbox = self._axis_scale_viewbox(axis)
|
|
1253
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1254
|
+
viewbox.invertX(checked)
|
|
1255
|
+
else:
|
|
1256
|
+
viewbox.invertY(checked)
|
|
1257
|
+
|
|
1258
|
+
def _axis_scale_log_is_supported(self, axis: _AxisName) -> bool:
|
|
1259
|
+
"""Return whether this axis contains data items that support log mode."""
|
|
1260
|
+
|
|
1261
|
+
if getattr(self, "operation_kind", None) == "plot2d":
|
|
1262
|
+
return False
|
|
1263
|
+
for _viewbox, items in self._axis_scale_bound_item_groups(axis):
|
|
1264
|
+
candidates = items
|
|
1265
|
+
if candidates is None:
|
|
1266
|
+
candidates = list(getattr(self.plot, "dataItems", []))
|
|
1267
|
+
if any(callable(getattr(item, "setLogMode", None)) for item in candidates):
|
|
1268
|
+
return True
|
|
1269
|
+
return False
|
|
1270
|
+
|
|
1271
|
+
def _axis_scale_log_mode(self, axis: _AxisName) -> bool:
|
|
1272
|
+
"""Return the current log state from the displayed axis item."""
|
|
1273
|
+
|
|
1274
|
+
plot = self.__dict__.get("plot")
|
|
1275
|
+
get_axis = getattr(plot, "getAxis", None)
|
|
1276
|
+
if not callable(get_axis):
|
|
1277
|
+
return False
|
|
1278
|
+
axis_item = get_axis(_AXIS_SIDES[axis])
|
|
1279
|
+
return bool(getattr(axis_item, "logMode", False))
|
|
1280
|
+
|
|
1281
|
+
def _sync_axis_scale_line_log_mode(self, trace_key: Any, line: Any) -> None:
|
|
1282
|
+
"""Apply the log modes of a trace's assigned axes to that trace."""
|
|
1283
|
+
|
|
1284
|
+
set_log_mode = getattr(line, "setLogMode", None)
|
|
1285
|
+
if not callable(set_log_mode):
|
|
1286
|
+
return
|
|
1287
|
+
style = self.__dict__.get("_trace_styles", {}).get(trace_key)
|
|
1288
|
+
x_axis: _AxisName = (
|
|
1289
|
+
"x2" if getattr(style, "x_axis", "Bottom") == "Top" else "x"
|
|
1290
|
+
)
|
|
1291
|
+
y_axis: _AxisName = (
|
|
1292
|
+
"y2" if getattr(style, "y_axis", "Left") == "Right" else "y"
|
|
1293
|
+
)
|
|
1294
|
+
set_log_mode(
|
|
1295
|
+
self._axis_scale_log_mode(x_axis),
|
|
1296
|
+
self._axis_scale_log_mode(y_axis),
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
def _axis_scale_log_toggled(self, axis: _AxisName, checked: bool) -> None:
|
|
1300
|
+
"""Apply a tab's log state to its ticks and assigned line traces."""
|
|
1301
|
+
|
|
1302
|
+
if not self._axis_scale_log_is_supported(axis):
|
|
1303
|
+
self._sync_axis_scale_controls(axis)
|
|
1304
|
+
return
|
|
1305
|
+
|
|
1306
|
+
self.plot.getAxis(_AXIS_SIDES[axis]).setLogMode(checked)
|
|
1307
|
+
if axis in ("x", "y"):
|
|
1308
|
+
control_name = "logXCheck" if axis == "x" else "logYCheck"
|
|
1309
|
+
plot_control = getattr(
|
|
1310
|
+
getattr(self.plot, "ctrl", None),
|
|
1311
|
+
control_name,
|
|
1312
|
+
None,
|
|
1313
|
+
)
|
|
1314
|
+
if plot_control is not None:
|
|
1315
|
+
plot_control.blockSignals(True)
|
|
1316
|
+
try:
|
|
1317
|
+
plot_control.setChecked(checked)
|
|
1318
|
+
finally:
|
|
1319
|
+
plot_control.blockSignals(False)
|
|
1320
|
+
styles = self.__dict__.get("_trace_styles")
|
|
1321
|
+
lines = self.__dict__.get("lines")
|
|
1322
|
+
if isinstance(styles, dict) and isinstance(lines, dict):
|
|
1323
|
+
for trace_key, line in lines.items():
|
|
1324
|
+
style = styles.get(trace_key)
|
|
1325
|
+
if self._axis_scale_dimension(axis) == "x":
|
|
1326
|
+
assigned_axis = (
|
|
1327
|
+
"x2"
|
|
1328
|
+
if getattr(style, "x_axis", "Bottom") == "Top"
|
|
1329
|
+
else "x"
|
|
1330
|
+
)
|
|
1331
|
+
else:
|
|
1332
|
+
assigned_axis = (
|
|
1333
|
+
"y2"
|
|
1334
|
+
if getattr(style, "y_axis", "Left") == "Right"
|
|
1335
|
+
else "y"
|
|
1336
|
+
)
|
|
1337
|
+
if assigned_axis == axis:
|
|
1338
|
+
self._sync_axis_scale_line_log_mode(trace_key, line)
|
|
1339
|
+
else:
|
|
1340
|
+
axis_number = self._axis_scale_axis_number(axis)
|
|
1341
|
+
for item in getattr(self.plot, "dataItems", []):
|
|
1342
|
+
set_log_mode = getattr(item, "setLogMode", None)
|
|
1343
|
+
if not callable(set_log_mode):
|
|
1344
|
+
continue
|
|
1345
|
+
current = list(getattr(item, "opts", {}).get("logMode", (False, False)))
|
|
1346
|
+
current[axis_number] = checked
|
|
1347
|
+
set_log_mode(bool(current[0]), bool(current[1]))
|
|
1348
|
+
|
|
1349
|
+
if self._axis_scale_uses_filtered_auto(axis):
|
|
1350
|
+
self._apply_axis_scale_filtered_auto(axis)
|
|
1351
|
+
else:
|
|
1352
|
+
self._axis_scale_viewbox(axis).enableAutoRange(
|
|
1353
|
+
self._axis_scale_axis_constant(axis),
|
|
1354
|
+
True,
|
|
1355
|
+
)
|
|
1356
|
+
self._sync_axis_scale_controls(axis)
|
|
1357
|
+
self._view_range_changed_programmatically()
|
|
1358
|
+
|
|
1359
|
+
def _axis_scale_axis_is_used(self, axis: _AxisName) -> bool:
|
|
1360
|
+
"""Return whether the plot currently has an item on this axis."""
|
|
1361
|
+
|
|
1362
|
+
styles = self.__dict__.get("_trace_styles")
|
|
1363
|
+
lines = self.__dict__.get("lines")
|
|
1364
|
+
if not isinstance(styles, dict) or not isinstance(lines, dict):
|
|
1365
|
+
assignments = self.__dict__.get("_heatmap_axis_assignments")
|
|
1366
|
+
if isinstance(assignments, dict):
|
|
1367
|
+
attribute, value = {
|
|
1368
|
+
"x": ("x", "Bottom"),
|
|
1369
|
+
"y": ("y", "Left"),
|
|
1370
|
+
"x2": ("x", "Top"),
|
|
1371
|
+
"y2": ("y", "Right"),
|
|
1372
|
+
}[axis]
|
|
1373
|
+
return any(
|
|
1374
|
+
assignment.get(attribute) == value
|
|
1375
|
+
for assignment in assignments.values()
|
|
1376
|
+
)
|
|
1377
|
+
return axis in ("x", "y")
|
|
1378
|
+
|
|
1379
|
+
attribute, value = {
|
|
1380
|
+
"x": ("x_axis", "Bottom"),
|
|
1381
|
+
"y": ("y_axis", "Left"),
|
|
1382
|
+
"x2": ("x_axis", "Top"),
|
|
1383
|
+
"y2": ("y_axis", "Right"),
|
|
1384
|
+
}[axis]
|
|
1385
|
+
return any(
|
|
1386
|
+
line is not None
|
|
1387
|
+
and getattr(styles.get(trace_key), attribute, None) == value
|
|
1388
|
+
for trace_key, line in lines.items()
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
def _create_axis_scale_dialog(self) -> qtw.QDialog:
|
|
1392
|
+
"""Create the shared four-tab scaling dialog."""
|
|
1393
|
+
|
|
1394
|
+
dialog = qtw.QDialog(self)
|
|
1395
|
+
dialog.setWindowTitle(self._axis_scale_dialog_title())
|
|
1396
|
+
layout = qtw.QVBoxLayout(dialog)
|
|
1397
|
+
layout.setContentsMargins(12, 12, 12, 12)
|
|
1398
|
+
layout.setSpacing(10)
|
|
1399
|
+
tabs = qtw.QTabWidget(dialog)
|
|
1400
|
+
tabs.setObjectName("axisScaleTabs")
|
|
1401
|
+
tabs.setFixedWidth(300)
|
|
1402
|
+
tabs.tabBar().setExpanding(True)
|
|
1403
|
+
tabs.tabBar().setUsesScrollButtons(False)
|
|
1404
|
+
tabs.setStyleSheet(
|
|
1405
|
+
"QTabBar::tab { min-width: 57px; padding-left: 8px; "
|
|
1406
|
+
"padding-right: 8px; } "
|
|
1407
|
+
"QTabBar::tab:selected { background: palette(highlight); "
|
|
1408
|
+
"color: palette(highlighted-text); font-weight: 600; }"
|
|
1409
|
+
)
|
|
1410
|
+
tab_tooltips = {
|
|
1411
|
+
"x": "Bottom horizontal axis",
|
|
1412
|
+
"y": "Left vertical axis",
|
|
1413
|
+
"x2": "Top horizontal axis",
|
|
1414
|
+
"y2": "Right vertical axis",
|
|
1415
|
+
}
|
|
1416
|
+
for axis, label, _side in _AXIS_SPECS:
|
|
1417
|
+
index = tabs.addTab(self._new_axis_scale_controls(axis), label)
|
|
1418
|
+
tabs.setTabToolTip(index, tab_tooltips[axis])
|
|
1419
|
+
tabs.currentChanged.connect(self._axis_scale_current_tab_changed)
|
|
1420
|
+
layout.addWidget(tabs)
|
|
1421
|
+
|
|
1422
|
+
buttons = qtw.QDialogButtonBox(qtw.QDialogButtonBox.StandardButton.Close)
|
|
1423
|
+
buttons.setContentsMargins(0, 0, 28, 0)
|
|
1424
|
+
buttons.rejected.connect(dialog.close)
|
|
1425
|
+
layout.addWidget(buttons)
|
|
1426
|
+
layout.setSizeConstraint(qtw.QLayout.SizeConstraint.SetFixedSize)
|
|
1427
|
+
|
|
1428
|
+
self._axis_scale_tabs = tabs
|
|
1429
|
+
self._axis_scale_dialog = dialog
|
|
1430
|
+
return dialog
|
|
1431
|
+
|
|
1432
|
+
def _axis_scale_current_tab_changed(self, index: int) -> None:
|
|
1433
|
+
"""Refresh a tab whenever the user switches to it."""
|
|
1434
|
+
|
|
1435
|
+
if not 0 <= index < len(_AXIS_SPECS):
|
|
1436
|
+
return
|
|
1437
|
+
axis = _AXIS_SPECS[index][0]
|
|
1438
|
+
if self._axis_scale_axis_is_used(axis):
|
|
1439
|
+
self._sync_axis_scale_controls(axis)
|
|
1440
|
+
|
|
1441
|
+
def _sync_axis_scale_tab_states(self) -> None:
|
|
1442
|
+
"""Grey out tabs for axes without any assigned trace."""
|
|
1443
|
+
|
|
1444
|
+
tabs = self.__dict__.get("_axis_scale_tabs")
|
|
1445
|
+
if tabs is None:
|
|
1446
|
+
return
|
|
1447
|
+
for index, (axis, _label, _side) in enumerate(_AXIS_SPECS):
|
|
1448
|
+
enabled = self._axis_scale_axis_is_used(axis)
|
|
1449
|
+
tabs.setTabEnabled(index, enabled)
|
|
1450
|
+
if enabled and axis in self._axis_scale_controls:
|
|
1451
|
+
self._sync_axis_scale_controls(axis)
|
|
1452
|
+
|
|
1453
|
+
@QtCore.pyqtSlot(str)
|
|
1454
|
+
def open_axis_scale_dialog(self, axis: str) -> None:
|
|
1455
|
+
"""
|
|
1456
|
+
Open the shared scaling dialog on the requested axis tab.
|
|
1457
|
+
|
|
1458
|
+
"""
|
|
1459
|
+
if hasattr(self.vb, "updateViewLists"):
|
|
1460
|
+
self.vb.updateViewLists()
|
|
1461
|
+
|
|
1462
|
+
if hasattr(self.vbMenu, "updateState"):
|
|
1463
|
+
self.vbMenu.updateState()
|
|
1464
|
+
|
|
1465
|
+
axis_name = _AXIS_ALIASES.get(axis.lower())
|
|
1466
|
+
if axis_name is None:
|
|
1467
|
+
return
|
|
1468
|
+
|
|
1469
|
+
dialog = self.__dict__.get("_axis_scale_dialog")
|
|
1470
|
+
if dialog is None:
|
|
1471
|
+
dialog = self._create_axis_scale_dialog()
|
|
1472
|
+
|
|
1473
|
+
self._sync_axis_scale_tab_states()
|
|
1474
|
+
|
|
1475
|
+
tabs = self._axis_scale_tabs
|
|
1476
|
+
requested_index = next(
|
|
1477
|
+
index
|
|
1478
|
+
for index, (current_axis, _label, _side) in enumerate(_AXIS_SPECS)
|
|
1479
|
+
if current_axis == axis_name
|
|
1480
|
+
)
|
|
1481
|
+
if tabs.isTabEnabled(requested_index):
|
|
1482
|
+
tabs.setCurrentIndex(requested_index)
|
|
1483
|
+
dialog.show()
|
|
1484
|
+
dialog.raise_()
|
|
1485
|
+
dialog.activateWindow()
|