PySide6Plot 0.0.1__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.
@@ -0,0 +1,750 @@
1
+ from pyqtgraph import PlotWidget, SignalProxy, AxisItem
2
+ from PySide6.QtCore import Qt, Signal, QRectF
3
+ from math import ceil, log10
4
+ from qfluentwidgets import (
5
+ qconfig,
6
+ Theme,
7
+ isDarkTheme,
8
+ MenuAnimationType,
9
+ FluentIcon,
10
+ Action,
11
+ RoundMenu,
12
+ MenuIndicatorType,
13
+ CheckableMenu,
14
+ PillPushButton,
15
+ )
16
+ from pyqtgraph import PlotCurveItem
17
+ from ..libs.style import LIGHT_BACKGROUND_COLOR, DARK_BACKGROUND_COLOR
18
+ from ..libs.constant import ZOOM_MODEL, YLOC_MODEL, SCALE_LOC_MODEL
19
+ from ..libs.helpers import limit_in_range, GeneralDataClass
20
+ from .value_select_box import select_value
21
+
22
+
23
+ class CustomizedAxis(AxisItem):
24
+ def __init__(
25
+ self,
26
+ orientation,
27
+ plot_strs=None,
28
+ pen=None,
29
+ textPen=None,
30
+ tickPen=None,
31
+ linkView=None,
32
+ parent=None,
33
+ maxTickLength=-5,
34
+ showValues=True,
35
+ text="",
36
+ units="",
37
+ unitPrefix="",
38
+ **args,
39
+ ):
40
+ """
41
+ CustomizedAxis class represents a customized axis for a plot.
42
+
43
+ Args:
44
+ orientation (str): The orientation of the axis. Can be 'left', 'right', 'top', or 'bottom'.
45
+ plot_strs (dict): A dictionary mapping index values to plot strings.
46
+ pen (QPen): The pen used to draw the axis line.
47
+ textPen (QPen): The pen used to draw the axis labels.
48
+ tickPen (QPen): The pen used to draw the axis ticks.
49
+ linkView (ViewBox): The view box to link the axis to.
50
+ parent (QObject): The parent object of the axis.
51
+ maxTickLength (int): The maximum length of the tick lines.
52
+ showValues (bool): Whether to show the tick values.
53
+ text (str): The text to display next to the axis.
54
+ units (str): The units of the axis values.
55
+ unitPrefix (str): The prefix to use for the units.
56
+ **args: Additional keyword arguments.
57
+ """
58
+ super().__init__(
59
+ orientation,
60
+ pen,
61
+ textPen,
62
+ tickPen,
63
+ linkView,
64
+ parent,
65
+ maxTickLength,
66
+ showValues,
67
+ text,
68
+ units,
69
+ unitPrefix,
70
+ **args,
71
+ )
72
+ self.plot_strs = plot_strs
73
+ self.min_index = 0
74
+ self.max_index = 0
75
+
76
+ def set_tick_strings(self, plot_strs):
77
+ """
78
+ Set the plot strings for the axis.
79
+
80
+ Args:
81
+ plot_strs (dict): A dictionary mapping index values to plot strings.
82
+ """
83
+ self.plot_strs = plot_strs
84
+ indexs = list(plot_strs.keys())
85
+ self.min_index = indexs[0]
86
+ self.max_index = indexs[-1]
87
+
88
+ def tick_str(self, value):
89
+ """
90
+ Return the tick string for the given value.
91
+
92
+ Args:
93
+ value (float): The value of the tick.
94
+
95
+ Returns:
96
+ str: The tick string.
97
+ """
98
+ if self.plot_strs is None:
99
+ return f"{value:.1f}"
100
+ else:
101
+ if value >= self.min_index and value <= self.max_index:
102
+ return self.plot_strs[round(value)]
103
+ else:
104
+ return " "
105
+
106
+ def tickStrings(self, values, zoom, spacing):
107
+ """
108
+ Return the strings that should be placed next to ticks.
109
+
110
+ This method is called when redrawing the axis and is a good method to override in subclasses.
111
+ The method is called with a list of tick values, a scaling factor, and the spacing between ticks.
112
+
113
+ Args:
114
+ values (list): The list of tick values.
115
+ zoom (float): The scaling factor for the axis label.
116
+ spacing (float): The spacing between ticks.
117
+
118
+ Returns:
119
+ list: The list of tick strings.
120
+ """
121
+ if self.logMode:
122
+ return self.logTickStrings(values, zoom, spacing)
123
+
124
+ places = max(0, ceil(-log10(spacing * zoom)))
125
+ if self.plot_strs is None:
126
+ strings = []
127
+ for v in values:
128
+ vs = v * zoom
129
+ vstr = f"{vs:.{places}f}" if 1e-3 <= abs(vs) < 1e4 else f"{vs:g}"
130
+ strings.append(vstr)
131
+ else:
132
+ strings = [self.plot_strs[int(v)] if (self.min_index <= v <= self.max_index) else " " for v in values]
133
+ return strings
134
+
135
+
136
+ # NOTE: the y of viewRect is reversed, i.e. the top is the max value and the bottom is the min value
137
+ class QPlotWidget(PlotWidget):
138
+ sigBoundingUpdated = Signal()
139
+ sigSizeChanged = Signal()
140
+ sigZoomModelChanged = Signal()
141
+ sigYLocModelChanged = Signal()
142
+ sigMouseLeaved = Signal()
143
+ sigEnterPressed = Signal()
144
+ sigEscapePressed = Signal()
145
+ sigViewChanged = Signal()
146
+ sigViewChangedByDrag = Signal()
147
+ sigViewChangedNotByDrag = Signal()
148
+ sigItemAdded = Signal()
149
+ sigItemRemoved = Signal()
150
+
151
+ def __init__(self, parent=None, background="default", plotItem=None, **kargs):
152
+ super().__init__(parent, background, plotItem, **kargs)
153
+ self.__init_configuration()
154
+ self.__init_variables()
155
+ self.__init__config_variables()
156
+ self.__init_connections()
157
+ self.__init_context_menu()
158
+
159
+ self.__on_theme_changed()
160
+ self.set_zoom_model(ZOOM_MODEL.AUTO_RANGE)
161
+ self.set_y_loc_model(YLOC_MODEL.DATA_CENTERED)
162
+
163
+ def __init_configuration(self):
164
+ self.setMouseEnabled(x=True, y=False)
165
+ self.setMenuEnabled(False)
166
+ self.setAxisItems({"bottom": CustomizedAxis(orientation="bottom")})
167
+ self.setAxisItems({"left": CustomizedAxis(orientation="left")})
168
+ self.getAxis("bottom").setHeight(32)
169
+ self.getAxis("bottom").setStyle(tickTextOffset=10)
170
+ self.getAxis("left").setWidth(70)
171
+ self.getAxis("left").setStyle(tickTextOffset=10)
172
+ self.setCursor(Qt.CursorShape.CrossCursor)
173
+ self.showGrid(x=True, y=True)
174
+
175
+ def __init_variables(self):
176
+ self.plotted_items = []
177
+ self.x_start = None
178
+ self.x_end = None
179
+ self.y_end = None
180
+ self.y_start = None
181
+ self.__reset_bounding()
182
+ self.fixed_yx_ratio = (self.x_end - self.x_start) / (self.y_start - self.y_end)
183
+ self.fixed_y_loc = self.y_start
184
+ self.fixed_y_range = self.y_end - self.y_start
185
+ self.x_start_button_held = False
186
+ self.zoom_model = ZOOM_MODEL.AUTO_RANGE
187
+ self.y_loc_model = YLOC_MODEL.DATA_CENTERED
188
+ self.move_from_code = False
189
+
190
+ def __init__config_variables(self):
191
+ self.y_autorange_bounding_factor = 0.05
192
+ self.zoom_loc_model = SCALE_LOC_MODEL.RIGHT
193
+
194
+ def __init_connections(self):
195
+ self.view_changed_slot = SignalProxy(self.sigRangeChanged, rateLimit=50, slot=self.__on_range_changed)
196
+ self.mouse_move_slot = SignalProxy(self.scene().sigMouseMoved, rateLimit=50, slot=self.__show_loc)
197
+ self.show_cursor_slot = SignalProxy(self.scene().sigMouseMoved, rateLimit=50, slot=self.__update_cursor)
198
+ qconfig.themeChanged.connect(self.__on_theme_changed)
199
+ self.sigBoundingUpdated.connect(
200
+ lambda: self.update_plot(x_loc=self.viewRect().left(), x_range=self.viewRect().width())
201
+ )
202
+
203
+ def __init_context_menu(self):
204
+ self.context_menu_actions = GeneralDataClass(
205
+ zoom_models=GeneralDataClass(
206
+ auto_range=Action("Adaptive"),
207
+ fixed_aspect_ratio=Action("Fixed aspect ratio"),
208
+ fixed_yrange=Action("Fixed y range"),
209
+ ),
210
+ y_loc_models=GeneralDataClass(
211
+ data_centered=Action("Data centered"),
212
+ fixed=Action("Fixed y location"),
213
+ free=Action("Free to move"),
214
+ ),
215
+ )
216
+ for _, action in self.context_menu_actions.zoom_models:
217
+ action.setCheckable(True)
218
+ self.context_menu_actions.zoom_models.auto_range.setChecked(True)
219
+ for _, action in self.context_menu_actions.y_loc_models:
220
+ action.setCheckable(True)
221
+ self.context_menu_actions.zoom_models.auto_range.triggered.connect(
222
+ lambda: self.set_zoom_model(ZOOM_MODEL.AUTO_RANGE)
223
+ )
224
+ self.context_menu_actions.zoom_models.fixed_aspect_ratio.triggered.connect(
225
+ lambda: self.set_zoom_model(ZOOM_MODEL.FIXED_RATIO)
226
+ )
227
+ self.context_menu_actions.zoom_models.fixed_yrange.triggered.connect(
228
+ lambda: self.set_zoom_model(ZOOM_MODEL.FIXED_YRANGE)
229
+ )
230
+ self.context_menu_actions.y_loc_models.free.triggered.connect(lambda: self.set_y_loc_model(YLOC_MODEL.FREE))
231
+ self.context_menu_actions.y_loc_models.data_centered.triggered.connect(
232
+ lambda: self.set_y_loc_model(YLOC_MODEL.DATA_CENTERED)
233
+ )
234
+ self.context_menu_actions.y_loc_models.fixed.triggered.connect(lambda: self.set_y_loc_model(YLOC_MODEL.FIXED))
235
+ self.zoom_model_menu = CheckableMenu("Zoom model", self, indicatorType=MenuIndicatorType.RADIO)
236
+ self.zoom_model_menu.setIcon(FluentIcon.ZOOM)
237
+ self.zoom_model_menu.addActions([action for _, action in self.context_menu_actions.zoom_models])
238
+ self.zoom_model_menu.addSeparator()
239
+ self.zoom_model_menu.addActions([action for _, action in self.context_menu_actions.y_loc_models])
240
+ self.context_menu = RoundMenu(parent=self)
241
+ self.context_menu.addMenu(self.zoom_model_menu)
242
+
243
+ def __reset_bounding(self):
244
+ view_rect = self.viewRect()
245
+ self.x_start = view_rect.left()
246
+ self.x_end = view_rect.right()
247
+ self.y_end = view_rect.bottom()
248
+ self.y_start = view_rect.top()
249
+ self.__on_plot_bounding_updated()
250
+ self.sigBoundingUpdated.emit()
251
+
252
+ def __on_plot_bounding_updated(self):
253
+ # the reason for not conncecting this function to sigBoundingUpdated is that
254
+ # we need to update the bounding rect before emitting the signal
255
+ # so that outside world can get the updated range_max/min
256
+ self.x_range_max = self.x_end - self.x_start
257
+ self.x_range_min = self.x_range_max / 1000
258
+ # self.x_range_min=5
259
+ self.y_range_max = self.y_end - self.y_start
260
+ self.y_range_min = self.y_range_max / 1000
261
+ self.setLimits(
262
+ xMin=self.x_start,
263
+ xMax=self.x_end,
264
+ yMin=self.y_start,
265
+ yMax=self.y_end,
266
+ minXRange=self.x_range_min,
267
+ maxXRange=self.x_range_max,
268
+ minYRange=self.y_range_min,
269
+ maxYRange=self.y_range_max,
270
+ )
271
+
272
+ def __on_range_changed(self):
273
+ self.sigViewChanged.emit()
274
+ if not self.move_from_code:
275
+ self.update_plot()
276
+ self.sigViewChangedByDrag.emit()
277
+ else:
278
+ self.move_from_code = False
279
+ self.sigViewChangedNotByDrag.emit()
280
+
281
+ def __on_theme_changed(self, theme=None):
282
+ """
283
+ Callback method triggered when the theme is changed.
284
+
285
+ Args:
286
+ theme (Theme): The new theme.
287
+
288
+ Returns:
289
+ None
290
+ """
291
+ if theme == Theme.DARK:
292
+ self.setBackground(DARK_BACKGROUND_COLOR)
293
+ elif theme == Theme.LIGHT:
294
+ self.setBackground(LIGHT_BACKGROUND_COLOR)
295
+ else:
296
+ if isDarkTheme():
297
+ self.setBackground(DARK_BACKGROUND_COLOR)
298
+ else:
299
+ self.setBackground(LIGHT_BACKGROUND_COLOR)
300
+
301
+ def __plot_bounding(self):
302
+ x_starts = []
303
+ x_ends = []
304
+ y_ends = []
305
+ y_starts = []
306
+ for item in self.plotted_items:
307
+ x_starts.append(item.boundingRect().left())
308
+ x_ends.append(item.boundingRect().right())
309
+ y_ends.append(item.boundingRect().bottom())
310
+ y_starts.append(item.boundingRect().top())
311
+ return min(x_starts), max(x_ends), min(y_starts), max(y_ends)
312
+
313
+ def __show_loc(self, event):
314
+ if not hasattr(self, "loc_xlabel"):
315
+ # pen = pg.mkPen(self.main_item.style.cross_line_color, width=1)
316
+ # self.vline = pg.InfiniteLine(angle=90, movable=False, pen=pen)
317
+ # self.hline = pg.InfiniteLine(angle=0, movable=False, pen=pen)
318
+ # self.addItem(self.vline, ignoreBounds=True)
319
+ # self.addItem(self.hline, ignoreBounds=True)
320
+ self.loc_xlabel = PillPushButton(parent=self)
321
+ self.loc_ylabel = PillPushButton(parent=self)
322
+ self.loc_ylabel.setFixedHeight(int(self.getAxis("bottom").height()))
323
+ self.loc_xlabel.setFixedHeight(int(self.getAxis("bottom").height()))
324
+ self.loc_ylabel.setFixedWidth(int(self.getAxis("left").width()))
325
+ # Use event.position() if available (PySide6), otherwise use event[0]
326
+ if hasattr(event, "position"):
327
+ pos = event.position()
328
+ else:
329
+ pos = event[0]
330
+ mouse_point = self.plotItem.vb.mapSceneToView(pos)
331
+ if self.viewRect().contains(mouse_point):
332
+ self.loc_ylabel.setText(str(round(mouse_point.y(), 1)))
333
+ self.loc_ylabel.move(0, int(pos.y() - self.loc_ylabel.geometry().height() / 2))
334
+ if self.loc_ylabel.isHidden():
335
+ self.loc_ylabel.show()
336
+ self.loc_xlabel.setText(self.getAxis("bottom").tick_str(mouse_point.x()))
337
+ self.loc_xlabel.move(
338
+ int(pos.x() - self.loc_xlabel.geometry().width() / 2),
339
+ int(self.geometry().height() - self.loc_xlabel.geometry().height()),
340
+ )
341
+ self.loc_xlabel.setFixedWidth(len(self.loc_xlabel.text()) * 10)
342
+ if self.loc_xlabel.isHidden():
343
+ self.loc_xlabel.show()
344
+ else:
345
+ self.loc_xlabel.hide()
346
+ self.loc_ylabel.hide()
347
+
348
+ def __update_cursor(self, event):
349
+ if self.x_start_button_held:
350
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
351
+ else:
352
+ mouse_point = self.plotItem.vb.mapSceneToView(event[0])
353
+ find = False
354
+ for item in self.scene().items():
355
+ if isinstance(item, PlotCurveItem):
356
+ if item.clickable and item.mouseShape().contains(mouse_point):
357
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
358
+ find = True
359
+ break
360
+ if not find:
361
+ self.setCursor(Qt.CursorShape.CrossCursor)
362
+
363
+ def __update_zoom_loc_menu(self):
364
+ """
365
+ Update the y range and location menu based on the current zoom model.
366
+
367
+ This method enables or disables the y location menu and sets the appropriate
368
+ checked state for the y location models based on the current zoom model.
369
+
370
+ Raises:
371
+ Exception: If the zoom model is invalid.
372
+ """
373
+
374
+ def enable_y_loc_menu(enable: bool):
375
+ if enable:
376
+ for _, actions in self.context_menu_actions.y_loc_models:
377
+ actions.setChecked(False)
378
+ actions.setVisible(True)
379
+ if self.y_loc_model == YLOC_MODEL.FREE:
380
+ self.context_menu_actions.y_loc_models.free.setChecked(True)
381
+ self.setMouseEnabled(x=True, y=True)
382
+ elif self.y_loc_model == YLOC_MODEL.DATA_CENTERED:
383
+ self.context_menu_actions.y_loc_models.data_centered.setChecked(True)
384
+ self.setMouseEnabled(x=True, y=False)
385
+ elif self.y_loc_model == YLOC_MODEL.FIXED:
386
+ self.context_menu_actions.y_loc_models.fixed.setChecked(True)
387
+ self.setMouseEnabled(x=True, y=False)
388
+ else:
389
+ raise Exception("Invalid y_loc model")
390
+ else:
391
+ for _, actions in self.context_menu_actions.y_loc_models:
392
+ actions.setChecked(False)
393
+ actions.setVisible(False)
394
+
395
+ if self.zoom_model == ZOOM_MODEL.AUTO_RANGE:
396
+ for _, actions in self.context_menu_actions.zoom_models:
397
+ actions.setChecked(False)
398
+ self.context_menu_actions.zoom_models.auto_range.setChecked(True)
399
+ self.setMouseEnabled(x=True, y=False)
400
+ enable_y_loc_menu(False)
401
+ elif self.zoom_model == ZOOM_MODEL.FIXED_RATIO:
402
+ for _, actions in self.context_menu_actions.zoom_models:
403
+ actions.setChecked(False)
404
+ self.context_menu_actions.zoom_models.fixed_aspect_ratio.setChecked(True)
405
+ enable_y_loc_menu(True)
406
+ elif self.zoom_model == ZOOM_MODEL.FIXED_YRANGE:
407
+ for _, actions in self.context_menu_actions.zoom_models:
408
+ actions.setChecked(False)
409
+ self.context_menu_actions.zoom_models.fixed_yrange.setChecked(True)
410
+ enable_y_loc_menu(True)
411
+ else:
412
+ raise Exception("Invalid zoom model")
413
+
414
+ def mouseReleaseEvent(self, event):
415
+ if event.button() == Qt.MouseButton.LeftButton:
416
+ self.setCursor(Qt.CursorShape.CrossCursor)
417
+ self.x_start_button_held = False
418
+ return super().mouseReleaseEvent(event)
419
+
420
+ def mousePressEvent(self, event):
421
+ if event.button() == Qt.MouseButton.LeftButton:
422
+ self.x_start_button_held = True
423
+ return super().mousePressEvent(event)
424
+
425
+ def contextMenuEvent(self, e):
426
+ # show context menu
427
+ self.context_menu.exec(e.globalPos(), aniType=MenuAnimationType.DROP_DOWN)
428
+
429
+ def leaveEvent(self, event):
430
+ if hasattr(self, "loc_xlabel"):
431
+ self.loc_xlabel.hide()
432
+ self.loc_ylabel.hide()
433
+ self.sigMouseLeaved.emit()
434
+ return super().leaveEvent(event)
435
+
436
+ def resizeEvent(self, event):
437
+ self.sigSizeChanged.emit()
438
+ return super().resizeEvent(event)
439
+
440
+ def add_item(self, plot_item, x_ticks=None, y_ticks=None):
441
+ """
442
+ Add a plot item to the plot widget.
443
+
444
+ Parameters:
445
+ plot_item (PlotItem): The plot item to be added.
446
+ x_ticks (list, optional): The tick labels for the x-axis. Defaults to None.
447
+ y_ticks (list, optional): The tick labels for the y-axis. Defaults to None.
448
+
449
+ Returns:
450
+ None
451
+ """
452
+ self.plotted_items.append(plot_item)
453
+ self.addItem(plot_item)
454
+ self.refresh_bounding(x_ticks, y_ticks)
455
+ self.sigItemAdded.emit()
456
+
457
+ def refresh_bounding(self, x_ticks=None, y_ticks=None):
458
+ if len(self.plotted_items) == 1:
459
+ self.x_start, self.x_end, self.y_start, self.y_end = self.__plot_bounding()
460
+ else:
461
+ x_start, x_end, y_start, y_end = self.__plot_bounding()
462
+ self.x_start = min(self.x_start, x_start)
463
+ self.x_end = max(self.x_end, x_end)
464
+ self.y_start = min(self.y_start, y_start)
465
+ self.y_end = max(self.y_end, y_end)
466
+ if x_ticks is not None:
467
+ self.getAxis("bottom").set_tick_strings(x_ticks)
468
+ if y_ticks is not None:
469
+ self.getAxis("left").set_tick_strings(y_ticks)
470
+
471
+ self.__on_plot_bounding_updated()
472
+ self.update_plot()
473
+ self.sigBoundingUpdated.emit()
474
+
475
+ def remove_item(self, plot_item):
476
+ """
477
+ Removes the specified plot item from the plot widget.
478
+
479
+ Args:
480
+ plot_item: The plot item to be removed.
481
+
482
+ Returns:
483
+ The return value of the removeItem() method.
484
+
485
+ """
486
+ self.plotted_items.remove(plot_item)
487
+ return_value = self.removeItem(plot_item)
488
+ if len(self.plotted_items) > 0:
489
+ self.x_start, self.x_end, self.y_start, self.y_end = self.__plot_bounding()
490
+ self.__on_plot_bounding_updated()
491
+ self.sigBoundingUpdated.emit()
492
+ else:
493
+ self.__reset_bounding()
494
+ self.update_plot()
495
+ self.sigItemRemoved.emit()
496
+ return return_value
497
+
498
+ def get_local_range(self, start, end):
499
+ """
500
+ Get the local plot range within the specified start and end values.
501
+
502
+ Args:
503
+ start (float): The start value of the range.
504
+ end (float): The end value of the range.
505
+
506
+ Returns:
507
+ tuple: A tuple containing the minimum and maximum values of the local plot range.
508
+ If no valid local range is found, the range of the view rectangle is returned.
509
+ """
510
+ mins, maxs = [], []
511
+ for item in self.plotted_items:
512
+ if hasattr(item, "get_local_plot_range"):
513
+ local_range = item.get_local_plot_range(start, end)
514
+ if local_range is not None:
515
+ mins.append(local_range[0])
516
+ maxs.append(local_range[1])
517
+ if len(mins) > 0:
518
+ return min(mins), max(maxs)
519
+ else:
520
+ return self.viewRect().top(), self.viewRect().bottom()
521
+
522
+ def update_plot(self, x_loc: float | None = None, x_range: float | None = None):
523
+ """
524
+ Update the plot with new x-location and x-range values.
525
+
526
+ Parameters:
527
+ - x_loc Optional[float]: The x-location of the plot. If None, the leftmost x-coordinate of the view rectangle is used.
528
+ - x_range Optional[float]: The x-range of the plot. If None, the width of the view rectangle is used.
529
+
530
+ Returns:
531
+ None
532
+ """
533
+ if x_loc is None and x_range is not None:
534
+ if self.zoom_loc_model == SCALE_LOC_MODEL.CENTRAL:
535
+ x_loc = (self.viewRect().left() + self.viewRect().right()) / 2 - x_range / 2
536
+ elif self.zoom_loc_model == SCALE_LOC_MODEL.LEFT:
537
+ x_loc = self.viewRect().left()
538
+ elif self.zoom_loc_model == SCALE_LOC_MODEL.RIGHT:
539
+ x_loc = self.viewRect().right() - x_range
540
+ else:
541
+ if x_loc is None:
542
+ x_loc = self.viewRect().left()
543
+ if x_range is None:
544
+ x_range = self.viewRect().width()
545
+ x_range = limit_in_range(x_range, self.x_range_min, self.x_range_max)
546
+ x_loc = limit_in_range(x_loc, self.x_start, self.x_end - x_range)
547
+ x_right = x_loc + x_range
548
+ view_rect = self.viewRect()
549
+ y_loc = view_rect.top()
550
+ y_range = view_rect.height()
551
+ y_center = y_loc + y_range / 2
552
+ # calculate the yzoom
553
+ if self.zoom_model == ZOOM_MODEL.AUTO_RANGE:
554
+ y_loc, y_top = self.get_local_range(x_loc, x_right)
555
+ y_range_bounding = self.y_autorange_bounding_factor * (y_top - y_loc)
556
+ y_loc -= y_range_bounding / 2
557
+ y_top += y_range_bounding / 2
558
+ y_range = y_top - y_loc
559
+ elif self.zoom_model in {ZOOM_MODEL.FIXED_RATIO, ZOOM_MODEL.FIXED_YRANGE}:
560
+ if self.zoom_model == ZOOM_MODEL.FIXED_RATIO:
561
+ y_range = x_range * self.fixed_yx_ratio
562
+ else:
563
+ y_range = self.fixed_y_range
564
+ # make sure the zoom is in y center in fixed ratio model
565
+ if self.y_loc_model == YLOC_MODEL.DATA_CENTERED:
566
+ y_start, y_end = self.get_local_range(x_loc, x_right)
567
+ y_loc = (y_start + y_end) / 2 - y_range / 2
568
+ elif self.y_loc_model == YLOC_MODEL.FREE:
569
+ y_loc = y_center - y_range / 2
570
+ elif self.y_loc_model == YLOC_MODEL.FIXED:
571
+ y_loc = self.fixed_y_loc
572
+ else:
573
+ raise Exception("Invalid y_loc model")
574
+ else:
575
+ raise Exception("Invalid zoom model")
576
+ # make sure the yzoom is in the range
577
+ y_loc = limit_in_range(y_loc, self.y_start, self.y_end - y_range)
578
+ y_range = limit_in_range(y_range, self.y_range_min, self.y_range_max)
579
+ self.move_from_code = True
580
+ self.setRange(QRectF(x_loc, y_loc, x_range, y_range), padding=0)
581
+
582
+ def move_y_loc(self, y_loc):
583
+ """
584
+ Move the y location of the plot.
585
+
586
+ Parameters:
587
+ y_loc (float): The new y location.
588
+
589
+ Raises:
590
+ Exception: If the y_loc_model is not YLOC_MODEL.FREE.
591
+ Exception: If the zoom_model is ZOOM_MODEL.AUTO_RANGE.
592
+
593
+ Returns:
594
+ None
595
+ """
596
+ if self.y_loc_model != YLOC_MODEL.FREE:
597
+ raise Exception("you can only move y in y_loc free model")
598
+ if self.zoom_model == ZOOM_MODEL.AUTO_RANGE:
599
+ raise Exception("you can not move y in auto_range model")
600
+ y_loc = limit_in_range(y_loc, self.y_start, self.y_end - self.viewRect().height())
601
+ self.move_from_code = True
602
+ self.setRange(
603
+ QRectF(self.viewRect().left(), y_loc, self.viewRect().width(), self.viewRect().height()), padding=0
604
+ )
605
+
606
+ def set_zoom_model(self, zoom_model):
607
+ """
608
+ Set the y range model for the plotter.
609
+
610
+ Parameters:
611
+ zoom_model (int): The zoom model to set. Should be one of the values from the ZOOM_MODEL enum.
612
+
613
+ Raises:
614
+ Exception: If an invalid zoom model is provided.
615
+ """
616
+ if zoom_model == ZOOM_MODEL.AUTO_RANGE:
617
+ self.zoom_model = ZOOM_MODEL.AUTO_RANGE
618
+ self.__update_zoom_loc_menu()
619
+ self.update_plot()
620
+ elif zoom_model == ZOOM_MODEL.FIXED_RATIO:
621
+ new_yx_ratio = select_value(
622
+ parent=self.window(),
623
+ title="Set aspect ratio",
624
+ allowed_min=0.1,
625
+ current=self.viewRect().height() / self.viewRect().width(),
626
+ )
627
+ if new_yx_ratio is not None:
628
+ self.fixed_yx_ratio = new_yx_ratio
629
+ self.zoom_model = ZOOM_MODEL.FIXED_RATIO
630
+ self.__update_zoom_loc_menu()
631
+ self.update_plot()
632
+ elif zoom_model == ZOOM_MODEL.FIXED_YRANGE:
633
+ new_y_range = select_value(
634
+ parent=self.window(),
635
+ title="Set y range",
636
+ allowed_min=self.y_range_min,
637
+ allowed_max=self.y_range_max,
638
+ current=self.viewRect().height(),
639
+ )
640
+ if new_y_range is not None:
641
+ self.fixed_y_range = new_y_range
642
+ self.zoom_model = ZOOM_MODEL.FIXED_YRANGE
643
+ self.__update_zoom_loc_menu()
644
+ self.update_plot()
645
+ else:
646
+ raise Exception("Invalid zoom model")
647
+ self.sigZoomModelChanged.emit()
648
+
649
+ def set_y_loc_model(self, y_loc_model):
650
+ """
651
+ Set the y location model for the plotter.
652
+
653
+ Parameters:
654
+ y_loc_model (YLOC_MODEL): The y location model to set.
655
+
656
+ Returns:
657
+ None
658
+ """
659
+ if y_loc_model == YLOC_MODEL.FREE:
660
+ self.y_loc_model = YLOC_MODEL.FREE
661
+ self.__update_zoom_loc_menu()
662
+ self.update_plot()
663
+ elif y_loc_model == YLOC_MODEL.DATA_CENTERED:
664
+ self.y_loc_model = YLOC_MODEL.DATA_CENTERED
665
+ self.__update_zoom_loc_menu()
666
+ self.update_plot()
667
+ elif y_loc_model == YLOC_MODEL.FIXED:
668
+ new_y_loc = select_value(
669
+ parent=self.window(),
670
+ title="Set y start location",
671
+ allowed_min=self.y_start,
672
+ allowed_max=self.y_end,
673
+ current=self.viewRect().top(),
674
+ )
675
+ if new_y_loc is not None:
676
+ self.y_loc = new_y_loc
677
+ self.fixed_y_loc = new_y_loc
678
+ self.y_loc_model = YLOC_MODEL.FIXED
679
+ self.__update_zoom_loc_menu()
680
+ self.update_plot()
681
+ else:
682
+ raise Exception("Invalid y_loc model")
683
+ self.sigYLocModelChanged.emit()
684
+
685
+ def add_context_menu(self, item: Action | RoundMenu):
686
+ if isinstance(item, Action):
687
+ self.context_menu.addAction(item)
688
+ elif isinstance(item, RoundMenu):
689
+ self.context_menu.addMenu(item)
690
+
691
+ def insert_context_menu(self, item: Action | RoundMenu):
692
+ """
693
+ Inserts an item into the context menu.
694
+
695
+ Parameters:
696
+ item (Union[Action, RoundMenu]): The item to be inserted into the context menu.
697
+
698
+ Returns:
699
+ None
700
+ """
701
+ if isinstance(item, Action):
702
+ item = self.context_menu._createActionItem(item, before=None)
703
+ self.context_menu.view.insertItem(0, item)
704
+ self.context_menu.adjustSize()
705
+ elif isinstance(item, RoundMenu):
706
+ item, w = self.context_menu._createSubMenuItem(item)
707
+ self.context_menu.view.insertItem(0, item)
708
+ self.context_menu.view.setItemWidget(item, w)
709
+
710
+ def set_full_range_enabled(self, enabled: bool):
711
+ """
712
+ Set whether the full range is enabled.
713
+
714
+ Parameters:
715
+ enabled (bool): Whether the full range is enabled.
716
+
717
+ Returns:
718
+ None
719
+ """
720
+ if enabled:
721
+ self.plotItem.showButtons()
722
+ else:
723
+ self.plotItem.hideButtons()
724
+
725
+ def set_x_range(self, x_start: float | None = None, x_end: float | None = None):
726
+ if x_start is not None:
727
+ self.x_start = x_start
728
+ if x_end is not None:
729
+ self.x_end = x_end
730
+ if x_start is not None or x_end is not None:
731
+ self.__on_plot_bounding_updated()
732
+ self.sigBoundingUpdated.emit()
733
+
734
+ def move_to_end(self):
735
+ """
736
+ Move the plot to the end (right side).
737
+ """
738
+ self.update_plot(x_loc=self.x_end - self.viewRect().width(), x_range=self.viewRect().width())
739
+
740
+ def move_to_start(self):
741
+ """
742
+ Move the plot to the start (left side).
743
+ """
744
+ self.update_plot(x_loc=self.x_start, x_range=self.viewRect().width())
745
+
746
+ def full_range(self):
747
+ """
748
+ Set the plot to the full range.
749
+ """
750
+ self.update_plot(x_loc=self.x_start, x_range=self.x_end - self.x_start)