"""Independent amplitude axes for SignalPlot-compatible Qt canvases.""" import math try: from PySide6.QtCore import QPointF, Qt from PySide6.QtGui import QColor except ImportError: from PySide2.QtCore import QPointF, Qt from PySide2.QtGui import QColor from protocan.plot import Bounds, Viewport def small_amplitude(values, threshold=30): magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None) return magnitude is not None and magnitude <= threshold def split_amplitudes(series, threshold=30): """Channels with finite peak magnitude <= 30 use the right amplitude axis.""" result = set() for channel in series: if not channel.visible or channel.discrete: continue if small_amplitude(channel.values, threshold): result.add(channel.key) return result class DualAxisPlot: """Mixin: left axis for large channels, right for small channels. Shift-wheel selects the axis by the pointer's half of the analog pane. The host supplies series_bounds/project_series rendering hooks. """ def __init__(self, *args, **kwargs): self.right_axis_keys = set() self.right_data_bounds = Bounds(0, 1, 0, 1) self.right_viewport = Viewport() self.right_axis_limits = None super().__init__(*args, **kwargs) def _sync_bounds(self): keys = split_amplitudes(self._series) if keys != self.right_axis_keys: self.right_viewport = Viewport() self.right_axis_keys = keys super()._sync_bounds() def set_plot_bounds(self, bounds, has_data=True, x_origin=0): if self.right_axis_keys and self.has_left_axis(): groups = [[], []] for series in self._series: if series.visible and not series.discrete: groups[series.key in self.right_axis_keys].extend( v for v in series.values if math.isfinite(v)) ranges = [] for values in groups: low, high = (min(values), max(values)) if values else (0, 1) pad = max((high - low) * .08, max(abs(low), abs(high)) * .01, 1e-6) ranges.append(Bounds(bounds.left, bounds.right, low - pad, high + pad)) bounds, self.right_data_bounds = ranges super().set_plot_bounds(bounds, has_data, x_origin) def has_left_axis(self): return any(s.visible and not s.discrete and s.values and s.key not in self.right_axis_keys for s in self._series) @property def right_visible_bounds(self): if not self.has_left_axis(): return self.visible_bounds right = (self.right_data_bounds.visible(self.core, self.right_viewport) if self.core else self.right_data_bounds) left = self.visible_bounds if self.lock_y and self.right_axis_limits is not None: return Bounds(left.left, left.right, *self.right_axis_limits) return Bounds(left.left, left.right, right.bottom, right.top) def _axis_editor_values(self): fields = super()._axis_editor_values() if not self.right_axis_keys: return fields if not self.has_left_axis(): return fields[:2] + [("Y справа min", fields[2][1]), ("Y справа max", fields[3][1])] right = self.right_visible_bounds return fields[:2] + [("Y слева min", fields[2][1]), ("Y слева max", fields[3][1]), ("Y справа min", right.bottom), ("Y справа max", right.top)] def _apply_axis_editor_values(self, values): if len(values) == 6: # Validate both ranges before changing either one. Bounds(*values[:4]).validated(self.core) right = Bounds(*values[:2], *values[4:]).validated(self.core) self.right_axis_limits = (right.bottom, right.top) super()._apply_axis_editor_values(values[:4]) def _lock_axis(self, axis, locked): if axis == 'y' and locked and self.right_axis_limits is None: bounds = self.right_visible_bounds self.right_axis_limits = (bounds.bottom, bounds.top) super()._lock_axis(axis, locked) if axis == 'y' and not locked: self.right_axis_limits = None def series_bounds(self, series): return self.right_visible_bounds if series.key in self.right_axis_keys else self.visible_bounds def plot_rect(self): rect = super().plot_rect() if self.right_axis_keys: rect.setWidth(max(1, rect.width() - 72)) return rect def right_axis_margin(self): return 72 if self.right_axis_keys else 0 def reset_viewport(self): self.right_viewport = Viewport() super().reset_viewport() def _capture_y_pan(self): return super()._capture_y_pan(), self.right_viewport def _apply_y_pan(self, fraction, initial): left, right = initial super()._apply_y_pan(fraction, left) if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): self.right_viewport = right.transform(self.core, pan_y=fraction) def reset_interaction(self): self.right_viewport = Viewport() super().reset_interaction() def wheelEvent(self, event): if not event.modifiers() & Qt.ShiftModifier: return super().wheelEvent(event) pos = event.position() if hasattr(event, 'position') else event.posF() rect = self.value_rect() if not rect.top() <= pos.y() <= rect.bottom(): event.ignore() return ticks = event.angleDelta().y() or event.angleDelta().x() if not ticks: ticks = (event.pixelDelta().y() or event.pixelDelta().x()) * 2 if ticks and self.core and not self.lock_y: factor = 1.25 ** max(-8, min(8, ticks / 120)) self.setFocus(Qt.MouseFocusReason) if self.right_axis_keys and self.has_left_axis() and pos.x() >= rect.center().x(): self.right_viewport = self.right_viewport.transform( self.core, 1, factor, focus_y=(pos.y() - rect.top()) / rect.height()) self.update() self.view_changed.emit() else: self._navigation_zoom(factor, True, pos) event.accept() def paint_axes_and_markers(self, painter): super().paint_axes_and_markers(painter) if not self.right_axis_keys or not self.core: return paint_right_axis(painter, self.core, self.value_rect(), self.right_visible_bounds) def paint_right_axis(painter, core, rect, bounds): painter.save() painter.setPen(QColor('#a4b8cd')) step = core.tick_step(bounds.top - bounds.bottom, rect.height() * 2) first = math.ceil(bounds.bottom / step) count = min(100, max(0, math.floor(bounds.top / step) - first + 1)) for index in range(count): value = (first + index) * step y = rect.top() + bounds.fraction(core, value, True) * rect.height() painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') painter.restore() class SingleAmplitudeAxis: """Apply the same side policy to a canvas exposing amplitude_values().""" def has_left_axis(self): return not small_amplitude(self.amplitude_values()) def plot_rect(self): rect = super().plot_rect() if not self.has_left_axis(): rect.setWidth(max(1, rect.width() - 72)) return rect def paint_axes_and_markers(self, painter): super().paint_axes_and_markers(painter) if self.core and not self.has_left_axis(): paint_right_axis(painter, self.core, self.value_rect(), self.visible_bounds)