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