"""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 split_amplitudes(series, minimum_ratio=10): """Split at the largest decade gap; zero-valued channels join small ones.""" magnitudes = sorted((max((abs(v) for v in s.values if math.isfinite(v)), default=0), s.key) for s in series if s.visible and not s.discrete and len(s.values)) positive = [(m, key) for m, key in magnitudes if m > 0] gaps = [(positive[i + 1][0] / positive[i][0], positive[i][0]) for i in range(len(positive) - 1)] if not gaps or max(gaps)[0] < minimum_ratio: return set() threshold = max(gaps)[1] return {key for magnitude, key in magnitudes if magnitude <= threshold} 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() 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: 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) @property def right_visible_bounds(self): right = (self.right_data_bounds.visible(self.core, self.right_viewport) if self.core else self.right_data_bounds) left = self.visible_bounds return Bounds(left.left, left.right, right.bottom, right.top) 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 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 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 rect, bounds = self.value_rect(), self.right_visible_bounds painter.save() painter.setPen(QColor('#a4b8cd')) step = self.core.tick_step(bounds.top - bounds.bottom, rect.height()) 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(self.core, value, True) * rect.height() painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') painter.restore()