125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
"""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()
|