"""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 from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении. def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета. step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ. labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы. return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел. def small_amplitude(values, threshold=30): extent = getattr(values, "finite_extent", None) if extent is not None: return max(abs(extent[0]), abs(extent[1])) <= threshold 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. Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем. Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии. 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( getattr(series.values, "finite_extent", None) or (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() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета. return rect def right_axis_margin(self): return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен. def reset_viewport(self): self.right_viewport = Viewport() super().reset_viewport() def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста. left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок. if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос. position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5. return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом. return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область. def _apply_y_pan(self, fraction, initial): left, right = initial if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии. super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси. if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y. 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.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X. 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(self, painter): super().paint_axes(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:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении. 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() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении. return rect def paint_axes(self, painter): super().paint_axes(painter) if self.core and not self.has_left_axis(): paint_right_axis(painter, self.core, self.value_rect(), self.visible_bounds)