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,612 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ import pyqtgraph as pg
7
+ from PyQt6 import QtCore, QtGui
8
+ from PyQt6 import QtWidgets as qtw
9
+
10
+ from ._commands import command_spec, command_with_status, create_action
11
+ from ._native_transforms import NativePlotDataItem
12
+
13
+ if TYPE_CHECKING:
14
+ class _Plot1DSnapBase(qtw.QMainWindow):
15
+ toolbarCo_ord: qtw.QToolBar
16
+ snap_to_trace_action: QtGui.QAction | None
17
+ trace_label: qtw.QLabel | None
18
+ lines: dict[Any, Any]
19
+ pos_labels: dict[str, qtw.QLabel]
20
+ plot: Any
21
+ right_vb: Any
22
+ snap_marker: Any
23
+ _snap_marker_view: Any
24
+ ds: Any
25
+ param: Any
26
+
27
+ def initLabels(self) -> None: ...
28
+
29
+ def initMenu(self) -> None: ...
30
+
31
+ def mouseMoved(self, pos: object) -> None: ...
32
+
33
+ def register_shortcut(
34
+ self,
35
+ action: QtGui.QAction,
36
+ shortcut: object,
37
+ status_tip: str | None = None,
38
+ ) -> None: ...
39
+
40
+ @staticmethod
41
+ def formatNum(num: float, sf: int = 3) -> str: ...
42
+
43
+ def format_cursor_number(self, value: float) -> str: ...
44
+
45
+ def _set_cursor_index_label(self, text: str) -> None: ...
46
+ def _cursor_1d_x_data(self) -> Any: ...
47
+ else:
48
+ class _Plot1DSnapBase:
49
+ pass
50
+
51
+
52
+ SNAP_TO_TRACE_COMMAND = command_spec("plot.snap_to_trace")
53
+ SNAP_TO_TRACE_SHORTCUT_LABEL = SNAP_TO_TRACE_COMMAND.shortcut_display_text()
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class _SnapTraceSample:
58
+ x_value: float
59
+ y_value: float
60
+ point_number: int
61
+ x_view_value: float = float("nan")
62
+ y_view_value: float = float("nan")
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _LineData:
67
+ x_view: npt.ArrayLike
68
+ y_view: npt.ArrayLike
69
+ x_raw: npt.ArrayLike
70
+ y_raw: npt.ArrayLike
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _NearestTracePoint:
75
+ label: Any
76
+ x_value: float
77
+ y_value: float
78
+ viewbox: Any
79
+ point_number: int
80
+ x_view_value: float
81
+ y_view_value: float
82
+
83
+ def __iter__(self):
84
+ """Retain the historical five-value private-method unpacking API."""
85
+
86
+ yield self.label
87
+ yield self.x_value
88
+ yield self.y_value
89
+ yield self.viewbox
90
+ yield self.point_number
91
+
92
+
93
+ def _nearest_trace_sample(
94
+ x_data: npt.ArrayLike,
95
+ y_data: npt.ArrayLike,
96
+ cursor_x: float,
97
+ cursor_y: float | None = None,
98
+ ) -> _SnapTraceSample | None:
99
+ """
100
+ Return the finite plotted sample nearest to a cursor X coordinate.
101
+
102
+ """
103
+ x_data = np.asarray(x_data, dtype=float)
104
+ y_data = np.asarray(y_data, dtype=float)
105
+ count = min(x_data.size, y_data.size)
106
+ if count == 0:
107
+ return None
108
+
109
+ x_data = x_data[:count]
110
+ y_data = y_data[:count]
111
+ finite = np.isfinite(x_data) & np.isfinite(y_data)
112
+ if not np.any(finite):
113
+ return None
114
+
115
+ finite_indices = np.flatnonzero(finite)
116
+ x_values = x_data[finite]
117
+ y_values = y_data[finite]
118
+ if cursor_y is None:
119
+ distances = np.abs(x_values - cursor_x)
120
+ else:
121
+ distances = np.square(x_values - cursor_x) + np.square(y_values - cursor_y)
122
+ index = int(np.argmin(distances))
123
+
124
+ return _SnapTraceSample(
125
+ x_value=float(x_values[index]),
126
+ y_value=float(y_values[index]),
127
+ point_number=int(finite_indices[index]) + 1,
128
+ )
129
+
130
+
131
+ def _line_is_snap_visible(line: object) -> bool:
132
+ """
133
+ Return whether a plotted line should participate in snap selection.
134
+
135
+ """
136
+ is_visible = getattr(line, "isVisible", None)
137
+ if callable(is_visible):
138
+ return bool(is_visible())
139
+ return True
140
+
141
+
142
+ def _line_snap_data(line: object | None) -> _LineData | None:
143
+ """
144
+ Return full trace samples with aligned physical and view coordinates.
145
+
146
+ Downsampling, clipping and dynamic range limiting only change rendering.
147
+ Neither display positions nor peak/mean aggregates identify raw samples.
148
+ Apply native processing before log mapping, keeping the resulting arrays
149
+ aligned for snapping and selection statistics.
150
+
151
+ """
152
+ if (line is None or not _line_is_snap_visible(line)
153
+ or getattr(line, "_qplot_precision_error", None)):
154
+ return None
155
+
156
+ get_data = getattr(line, "getData", None)
157
+ if not callable(get_data):
158
+ return None
159
+
160
+ view_data = get_data()
161
+ if view_data is None or view_data[0] is None or view_data[1] is None:
162
+ return None
163
+
164
+ get_original = getattr(line, "getOriginalDataset", None)
165
+ raw_data = get_original() if callable(get_original) else view_data
166
+ if raw_data is None or raw_data[0] is None or raw_data[1] is None:
167
+ raw_data = view_data
168
+ if callable(get_original):
169
+ opts = getattr(line, "opts", {})
170
+ processing = {
171
+ key: opts.get(key, False)
172
+ for key in ("subtractMeanMode", "fftMode", "derivativeMode", "phasemapMode")
173
+ }
174
+ if any(processing.values()):
175
+ # Share the rendering mapping, without display reduction.
176
+ analysis_line = NativePlotDataItem(
177
+ x=raw_data[0], y=raw_data[1], **processing,
178
+ )
179
+ raw_data = analysis_line.getData()
180
+ if raw_data[0] is None or raw_data[1] is None:
181
+ return None
182
+ # Native log-X drops DC within the FFT stage. Phase map runs later
183
+ # and replaces both coordinates with full input-derived samples,
184
+ # so that FFT slice must not remove a valid phase-map point here.
185
+ if (processing["fftMode"] and not processing["phasemapMode"]
186
+ and opts.get("logMode", (False, False))[0]):
187
+ raw_data = (raw_data[0][1:], raw_data[1][1:])
188
+ view_data = tuple(np.asarray(values, dtype=float) for values in raw_data)
189
+ log_mode = opts.get("logMode", (False, False))
190
+ mapped = []
191
+ for values, logarithmic in zip(view_data, log_mode, strict=True):
192
+ if logarithmic:
193
+ with np.errstate(divide="ignore", invalid="ignore"):
194
+ values = np.log10(values)
195
+ values[~np.isfinite(values)] = np.nan
196
+ mapped.append(values)
197
+ view_data = tuple(mapped)
198
+ return _LineData(
199
+ x_view=view_data[0],
200
+ y_view=view_data[1],
201
+ x_raw=raw_data[0],
202
+ y_raw=raw_data[1],
203
+ )
204
+
205
+
206
+ def _scene_distance_squared(
207
+ first: QtCore.QPointF,
208
+ second: QtCore.QPointF,
209
+ ) -> float:
210
+ """
211
+ Return squared distance between two scene points.
212
+
213
+ """
214
+ return (first.x() - second.x()) ** 2 + (first.y() - second.y()) ** 2
215
+
216
+
217
+ class Plot1DSnapMixin(_Plot1DSnapBase):
218
+ """Snap-to-trace cursor readout for 1D plot windows."""
219
+
220
+ def initLabels(self):
221
+ """
222
+ Sets up coordinate labels and trace snapping command for 1d plots.
223
+
224
+ """
225
+ super().initLabels()
226
+
227
+ self.trace_label = qtw.QLabel("")
228
+ self.trace_label.setMinimumWidth(0)
229
+ self.toolbarCo_ord.addWidget(self.trace_label)
230
+ self._update_coordinate_context()
231
+
232
+ self.snap_to_trace_action = create_action(
233
+ SNAP_TO_TRACE_COMMAND,
234
+ self,
235
+ text=f"Snap to Trace ({SNAP_TO_TRACE_SHORTCUT_LABEL})",
236
+ status_tip="Toggle snap-to-trace cursor readout",
237
+ checkable=True,
238
+ )
239
+ self.snap_to_trace_action.setToolTip(
240
+ "Lock the coordinate readout to the nearest plotted data point"
241
+ )
242
+ self.register_shortcut(
243
+ self.snap_to_trace_action,
244
+ command_with_status(
245
+ "plot.snap_to_trace",
246
+ "Toggle snap-to-trace cursor readout",
247
+ )
248
+ )
249
+ self.snap_to_trace_action.toggled.connect(self._snap_to_trace_toggled)
250
+
251
+
252
+ def initMenu(self):
253
+ """
254
+ Adds 1d-specific commands to the plot window menu bar.
255
+
256
+ """
257
+ super().initMenu()
258
+
259
+ view_menu = self._menu_by_title("&View")
260
+ if view_menu is None or self.snap_to_trace_action is None:
261
+ return
262
+
263
+ actions = view_menu.actions()
264
+ before = actions[0] if actions else None
265
+ view_menu.insertAction(before, self.snap_to_trace_action)
266
+ view_menu.insertSeparator(before)
267
+
268
+
269
+ def _menu_by_title(self, title):
270
+ """
271
+ Returns the menu matching a top-level menu title.
272
+
273
+ """
274
+ menu_bar = self.menuBar()
275
+ if menu_bar is None:
276
+ return None
277
+
278
+ for action in menu_bar.actions():
279
+ if action.text() == title:
280
+ return action.menu()
281
+ return None
282
+
283
+
284
+ @QtCore.pyqtSlot(bool)
285
+ def _snap_to_trace_toggled(self, enabled):
286
+ """
287
+ Handles the snap-to-trace toggle state.
288
+
289
+ """
290
+ if not enabled:
291
+ self._hide_snap_marker()
292
+ self._clear_snap_report()
293
+
294
+
295
+ @QtCore.pyqtSlot(object)
296
+ def mouseMoved(self, pos):
297
+ """
298
+ Updates the coordinate readout, optionally snapping to a 1d trace.
299
+
300
+ """
301
+ self._last_cursor_scene_pos = pos
302
+ if not (
303
+ self.snap_to_trace_action is not None
304
+ and self.snap_to_trace_action.isChecked()
305
+ ):
306
+ super().mouseMoved(pos)
307
+ return
308
+
309
+ if not self.plot.sceneBoundingRect().contains(pos):
310
+ self._hide_snap_marker()
311
+ self._clear_snap_report()
312
+ self._set_cursor_index_label("")
313
+ return
314
+
315
+ nearest = self._nearest_trace_point(pos)
316
+ if nearest is None:
317
+ self._hide_snap_marker()
318
+ self._clear_snap_report()
319
+ self._set_cursor_index_label("")
320
+ return
321
+
322
+ self._set_cursor_index_label(f"[{nearest.point_number - 1}]")
323
+ self.pos_labels["x"].setText(f"x = {self.format_cursor_number(nearest.x_value)}")
324
+ self.pos_labels["y"].setText(f"y = {self.format_cursor_number(nearest.y_value)}")
325
+ self._show_snap_report(nearest.label, nearest.point_number)
326
+ self._show_snap_marker(
327
+ nearest.x_view_value,
328
+ nearest.y_view_value,
329
+ nearest.viewbox,
330
+ )
331
+
332
+
333
+ def _show_snap_report(self, label, point_number):
334
+ """
335
+ Shows the active run, plotted axes, and snapped point.
336
+
337
+ """
338
+ self._update_coordinate_context(label, point_number)
339
+
340
+
341
+ def _clear_snap_report(self):
342
+ """
343
+ Restores the persistent run and axis relationship message.
344
+
345
+ """
346
+ self._update_coordinate_context()
347
+
348
+
349
+ def _update_coordinate_context(self, label=None, point_number=None):
350
+ """Show ``Run N: measurement vs setpoint`` and optional snap detail."""
351
+
352
+ if self.trace_label is None:
353
+ return
354
+
355
+ run_id, vertical, horizontal, tooltip = self._coordinate_context_parts(label)
356
+ message = f"Run {run_id}: {vertical} vs {horizontal}"
357
+ if point_number is not None:
358
+ message += f" (snapped to point {point_number - 1})"
359
+ self.trace_label.setText(message)
360
+ self.trace_label.setToolTip(tooltip)
361
+ self.trace_label.adjustSize()
362
+ self.trace_label.updateGeometry()
363
+ self.toolbarCo_ord.updateGeometry()
364
+
365
+
366
+ def _coordinate_context_parts(self, label=None):
367
+ """Return run and human-readable plotted-axis names for the message."""
368
+
369
+ line = self.lines.get(label) if label is not None else self.__dict__.get("line")
370
+ source = getattr(line, "from_win", self)
371
+ try:
372
+ dataset = source.ds
373
+ except (AttributeError, RuntimeError):
374
+ dataset = None
375
+ run_id = getattr(dataset, "run_id", "?")
376
+
377
+ host_options = self.__dict__.get("_axis_selection", {})
378
+ if not host_options:
379
+ try:
380
+ host_options = self.axis_options
381
+ except RuntimeError:
382
+ host_options = {}
383
+ horizontal_source = self
384
+ horizontal_axis = "x"
385
+ source_axis = "y"
386
+ if source is not self:
387
+ axis_order = getattr(line, "choose_from", None)
388
+ if axis_order is not None and len(axis_order) == 2:
389
+ horizontal_axis = axis_order[0]
390
+ source_axis = axis_order[1]
391
+ horizontal_source = source
392
+ source_options = (
393
+ host_options
394
+ if source is self
395
+ else getattr(source, "axis_options", {})
396
+ )
397
+ horizontal_name = source_options.get(horizontal_axis)
398
+ if horizontal_name is None:
399
+ horizontal_name = host_options.get("x", "x")
400
+ horizontal_source = self
401
+ horizontal_axis = "x"
402
+ horizontal = self._parameter_display_name(
403
+ horizontal_source,
404
+ horizontal_name,
405
+ horizontal_axis,
406
+ )
407
+ vertical_name = source_options.get(source_axis)
408
+ if vertical_name is None:
409
+ try:
410
+ source_param = source.param
411
+ except (AttributeError, RuntimeError):
412
+ source_param = None
413
+ vertical_name = getattr(source_param, "name", "y")
414
+ vertical = self._parameter_display_name(
415
+ source,
416
+ vertical_name,
417
+ source_axis,
418
+ )
419
+
420
+ display_label = getattr(self, "_trace_display_label", None)
421
+ if label is not None and callable(display_label):
422
+ tooltip = str(display_label(label, line))
423
+ else:
424
+ tooltip = str(getattr(source, "label", ""))
425
+ return run_id, vertical, horizontal, tooltip
426
+
427
+
428
+ @staticmethod
429
+ def _parameter_display_name(source, name, axis):
430
+ """Resolve a parameter label without discarding a useful name."""
431
+
432
+ param = getattr(source, "param_dict", {}).get(name)
433
+ if param is None:
434
+ axis_param = getattr(source, "axis_param", {})
435
+ candidate = axis_param.get(axis) if isinstance(axis_param, dict) else None
436
+ if getattr(candidate, "name", name) == name:
437
+ param = candidate
438
+ if param is None:
439
+ candidate = getattr(source, "param", None)
440
+ if getattr(candidate, "name", None) == name:
441
+ param = candidate
442
+ return str(getattr(param, "label", None) or getattr(param, "name", None) or name)
443
+
444
+ def _nearest_trace_point(self, scene_pos):
445
+ """
446
+ Finds the plotted data point nearest to the mouse position.
447
+
448
+ """
449
+ nearest = None
450
+ nearest_distance = None
451
+
452
+ for label, line in self.lines.items():
453
+ data = _line_snap_data(line)
454
+ if data is None:
455
+ continue
456
+
457
+ viewbox = self._viewbox_for_line(line)
458
+ sample, distance = self._nearest_scene_trace_sample(
459
+ data.x_view,
460
+ data.y_view,
461
+ scene_pos,
462
+ viewbox,
463
+ data.x_raw,
464
+ data.y_raw,
465
+ )
466
+ if sample is None:
467
+ continue
468
+
469
+ if nearest_distance is None or distance < nearest_distance:
470
+ nearest = _NearestTracePoint(
471
+ label=label,
472
+ x_value=sample.x_value,
473
+ y_value=sample.y_value,
474
+ viewbox=viewbox,
475
+ point_number=sample.point_number,
476
+ x_view_value=sample.x_view_value,
477
+ y_view_value=sample.y_view_value,
478
+ )
479
+ nearest_distance = distance
480
+
481
+ return nearest
482
+
483
+
484
+ @staticmethod
485
+ def _nearest_scene_trace_sample(
486
+ x_data,
487
+ y_data,
488
+ scene_pos,
489
+ viewbox,
490
+ raw_x_data=None,
491
+ raw_y_data=None,
492
+ ):
493
+ """Return the nearest full trace sample with aligned physical values."""
494
+
495
+ x_data = np.asarray(x_data, dtype=float)
496
+ y_data = np.asarray(y_data, dtype=float)
497
+ if raw_x_data is None:
498
+ raw_x_data = x_data
499
+ if raw_y_data is None:
500
+ raw_y_data = y_data
501
+ raw_x_data = np.asarray(raw_x_data, dtype=float)
502
+ raw_y_data = np.asarray(raw_y_data, dtype=float)
503
+ count = min(
504
+ x_data.size,
505
+ y_data.size,
506
+ raw_x_data.size,
507
+ raw_y_data.size,
508
+ )
509
+ if count == 0:
510
+ return None, None
511
+
512
+ x_data = x_data[:count]
513
+ y_data = y_data[:count]
514
+ raw_x_data = raw_x_data[:count]
515
+ raw_y_data = raw_y_data[:count]
516
+ finite = (
517
+ np.isfinite(x_data)
518
+ & np.isfinite(y_data)
519
+ & np.isfinite(raw_x_data)
520
+ & np.isfinite(raw_y_data)
521
+ )
522
+ if not np.any(finite):
523
+ return None, None
524
+
525
+ finite_indices = np.flatnonzero(finite)
526
+ x_values = x_data[finite]
527
+ y_values = y_data[finite]
528
+ raw_x_values = raw_x_data[finite]
529
+ raw_y_values = raw_y_data[finite]
530
+ # Read the affine coefficients directly: subtracting mapped unit
531
+ # points loses precision when a narrow sweep is far from zero.
532
+ # childTransform also updates the matrix after a range/geometry change.
533
+ transform = viewbox.childTransform() * viewbox.sceneTransform()
534
+ origin = viewbox.mapSceneToView(scene_pos)
535
+ scene_origin = viewbox.mapViewToScene(origin)
536
+ x_offsets = x_values - origin.x()
537
+ y_offsets = y_values - origin.y()
538
+ # Work near the pointer so neither the large absolute data coordinates
539
+ # nor the transform's large translation enter the vectorized mapping.
540
+ scene_dx = (
541
+ (scene_origin.x() - scene_pos.x())
542
+ + x_offsets * transform.m11()
543
+ + y_offsets * transform.m21()
544
+ )
545
+ scene_dy = (
546
+ (scene_origin.y() - scene_pos.y())
547
+ + x_offsets * transform.m12()
548
+ + y_offsets * transform.m22()
549
+ )
550
+ distances = np.square(scene_dx) + np.square(scene_dy)
551
+ index = int(np.argmin(distances))
552
+ sample = _SnapTraceSample(
553
+ x_value=float(raw_x_values[index]),
554
+ y_value=float(raw_y_values[index]),
555
+ point_number=int(finite_indices[index]) + 1,
556
+ x_view_value=float(x_values[index]),
557
+ y_view_value=float(y_values[index]),
558
+ )
559
+ return sample, float(distances[index])
560
+
561
+
562
+ def _viewbox_for_line(self, line):
563
+ """
564
+ Returns the viewbox that owns a plotted line.
565
+
566
+ """
567
+ get_viewbox = getattr(line, "getViewBox", None)
568
+ if callable(get_viewbox):
569
+ viewbox = get_viewbox()
570
+ if viewbox is not None:
571
+ return viewbox
572
+ if getattr(line, "side", "left") == "right" and self.right_vb is not None:
573
+ return self.right_vb
574
+
575
+ return self.plot.vb
576
+
577
+
578
+ def _show_snap_marker(self, x_value, y_value, viewbox):
579
+ """
580
+ Places a small marker on the snapped data point.
581
+
582
+ """
583
+ if self.snap_marker is None:
584
+ self.snap_marker = pg.ScatterPlotItem(
585
+ symbol="s",
586
+ size=3,
587
+ pen=pg.mkPen("k", width=1),
588
+ brush=pg.mkBrush("w"),
589
+ )
590
+ # The marker follows the pointer and is often directly over the
591
+ # trace. Keep it behind plot lines so the line receives a
592
+ # double-click before the marker can consume it.
593
+ self.snap_marker.setZValue(-1)
594
+
595
+ if self._snap_marker_view is not viewbox:
596
+ self._hide_snap_marker()
597
+ viewbox.addItem(self.snap_marker, ignoreBounds=True)
598
+ self._snap_marker_view = viewbox
599
+
600
+ self.snap_marker.setData([x_value], [y_value])
601
+
602
+
603
+ def _hide_snap_marker(self):
604
+ """
605
+ Removes the snap marker from whichever viewbox currently owns it.
606
+
607
+ """
608
+ if self.snap_marker is None or self._snap_marker_view is None:
609
+ return
610
+
611
+ self._snap_marker_view.removeItem(self.snap_marker)
612
+ self._snap_marker_view = None