Добавить общее перетаскивание графиков и независимые шкалы амплитуд
This commit is contained in:
108
python/set_devices/qt_ports/plot_drag.py
Normal file
108
python/set_devices/qt_ports/plot_drag.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Shared marker picking and axis-constrained plot drags (Qt 5/6).
|
||||||
|
|
||||||
|
The widget supplies plot/value rectangles, a PlotMath core, viewport/markers,
|
||||||
|
axis locks and refresh hooks. Geometry and numeric transforms use protocan.plot.
|
||||||
|
"""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QPointF, Qt
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
except ImportError:
|
||||||
|
from PySide2.QtCore import QPointF, Qt
|
||||||
|
from PySide2.QtWidgets import QApplication
|
||||||
|
|
||||||
|
|
||||||
|
DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — '
|
||||||
|
'перемещение по X; Shift + ЛКМ — перемещение по Y. '
|
||||||
|
'Щелчок мимо маркеров — одиночный курсор.')
|
||||||
|
|
||||||
|
|
||||||
|
def vertical_pan_requested(event):
|
||||||
|
return bool(event.modifiers() & Qt.ShiftModifier)
|
||||||
|
|
||||||
|
|
||||||
|
def cursor_hit(plot, position, value, event, radius=8):
|
||||||
|
"""Shift always pans; a cursor can only be grabbed near its visible line."""
|
||||||
|
return (not vertical_pan_requested(event) and value is not None
|
||||||
|
and plot.core is not None and plot.has_plot_data
|
||||||
|
and plot.visible_bounds.left <= value <= plot.visible_bounds.right
|
||||||
|
and plot.plot_rect().contains(position)
|
||||||
|
and abs(plot.project(value, 0, plot.plot_rect()).x() - position.x()) <= radius)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotDragInteraction:
|
||||||
|
"""Capture a hit marker or pan one axis, chosen at the start of the drag."""
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
if event.button() != Qt.LeftButton or not self.core:
|
||||||
|
return super().mousePressEvent(event)
|
||||||
|
pos = event.position() if hasattr(event, 'position') else event.localPos()
|
||||||
|
rect, values = self.plot_rect(), self.value_rect()
|
||||||
|
self._press = self._dragged = None
|
||||||
|
if not rect.contains(pos):
|
||||||
|
return
|
||||||
|
self.setFocus(Qt.MouseFocusReason)
|
||||||
|
self._press = QPointF(pos)
|
||||||
|
self._press_viewport = self.viewport
|
||||||
|
self._press_markers = self.markers
|
||||||
|
self._press_bounds = self.visible_bounds
|
||||||
|
self._pan_vertical = vertical_pan_requested(event)
|
||||||
|
self._pan_moved = False
|
||||||
|
if self.marker_mode.isChecked() and not self._pan_vertical and self.has_plot_data:
|
||||||
|
# X lines span digital lanes; Y lines only occupy the analog pane.
|
||||||
|
markers = self.markers if values.contains(pos) else replace(self.markers, y_enabled=False)
|
||||||
|
self._dragged = markers.hit(self.core, pos.x() - rect.left(),
|
||||||
|
pos.y() - values.top() if values.contains(pos) else 0,
|
||||||
|
rect.width(), values.height(), 12, self._press_bounds)
|
||||||
|
if self._dragged is not None:
|
||||||
|
self.markers = replace(self.markers, selected=self._dragged)
|
||||||
|
self._refresh_controls()
|
||||||
|
self.update()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
if not event.buttons() & Qt.LeftButton:
|
||||||
|
self._press = self._dragged = None
|
||||||
|
return super().mouseMoveEvent(event)
|
||||||
|
if self._press is None or not self.core:
|
||||||
|
return super().mouseMoveEvent(event)
|
||||||
|
pos = event.position() if hasattr(event, 'position') else event.localPos()
|
||||||
|
self._update_drag(pos)
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def _update_drag(self, pos):
|
||||||
|
delta = pos - self._press
|
||||||
|
self._pan_moved |= delta.manhattanLength() >= QApplication.startDragDistance()
|
||||||
|
rect, values = self.plot_rect(), self.value_rect()
|
||||||
|
if self._dragged is not None:
|
||||||
|
marker = self._dragged
|
||||||
|
self.markers = self._press_markers.drag(self.core, marker,
|
||||||
|
delta.y() if marker.horizontal else delta.x(),
|
||||||
|
values.height() if marker.horizontal else rect.width(), self._press_bounds)
|
||||||
|
else:
|
||||||
|
self.viewport = self._press_viewport.transform(self.core,
|
||||||
|
pan_x=delta.x() / rect.width() if not self._pan_vertical and not self.lock_x else 0,
|
||||||
|
pan_y=delta.y() / values.height() if self._pan_vertical and not self.lock_y else 0)
|
||||||
|
self._refresh_controls()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if event.button() != Qt.LeftButton:
|
||||||
|
return super().mouseReleaseEvent(event)
|
||||||
|
if self._press is not None and self.core:
|
||||||
|
# Qt may coalesce moves; always commit the final release position.
|
||||||
|
pos = event.position() if hasattr(event, 'position') else event.localPos()
|
||||||
|
self._update_drag(pos)
|
||||||
|
single_click = (self._dragged is None and not self._pan_vertical
|
||||||
|
and not self._pan_moved and self.plot_rect().contains(pos))
|
||||||
|
if single_click:
|
||||||
|
# A click with small hand jitter must not pan the time axis.
|
||||||
|
self.viewport = self._press_viewport
|
||||||
|
self._single_cursor_click(pos)
|
||||||
|
self.view_changed.emit()
|
||||||
|
self._press = self._dragged = None
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
def _single_cursor_click(self, pos):
|
||||||
|
"""Optional widget hook: return from paired markers to a single cursor."""
|
||||||
124
python/set_devices/qt_ports/plot_dual_axis.py
Normal file
124
python/set_devices/qt_ports/plot_dual_axis.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""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()
|
||||||
129
python/tests/test_plot_drag_qt.py
Normal file
129
python/tests/test_plot_drag_qt.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Shared drag gestures, with real Qt events and C math, without SETGUI imports."""
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, Qt, Signal
|
||||||
|
from PySide6.QtGui import QMouseEvent
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QCheckBox, QWidget
|
||||||
|
except ImportError:
|
||||||
|
from PySide2.QtCore import QEvent, QPoint, QPointF, QRectF, Qt, Signal
|
||||||
|
from PySide2.QtGui import QMouseEvent
|
||||||
|
from PySide2.QtTest import QTest
|
||||||
|
from PySide2.QtWidgets import QApplication, QCheckBox, QWidget
|
||||||
|
|
||||||
|
from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport
|
||||||
|
from set_devices.qt_ports.plot_drag import PlotDragInteraction
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas(PlotDragInteraction, QWidget):
|
||||||
|
view_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, core):
|
||||||
|
super().__init__()
|
||||||
|
self.core = core
|
||||||
|
self.viewport = Viewport(.25, .25, .5, .5)
|
||||||
|
self.markers = replace(Markers(a=40, b=60, e=50, f=65), y_enabled=True)
|
||||||
|
self.marker_mode = QCheckBox(self)
|
||||||
|
self.marker_mode.setChecked(True)
|
||||||
|
self.marker_mode.hide()
|
||||||
|
self.has_plot_data = True
|
||||||
|
self.lock_x = self.lock_y = False
|
||||||
|
self._press = self._dragged = None
|
||||||
|
self.single_clicks = []
|
||||||
|
self.resize(440, 340)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible_bounds(self):
|
||||||
|
return Bounds(0, 100, 0, 100).visible(self.core, self.viewport)
|
||||||
|
|
||||||
|
def plot_rect(self):
|
||||||
|
return QRectF(20, 20, 400, 300)
|
||||||
|
|
||||||
|
def value_rect(self):
|
||||||
|
return QRectF(20, 20, 400, 200)
|
||||||
|
|
||||||
|
def _refresh_controls(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _single_cursor_click(self, pos):
|
||||||
|
self.single_clicks.append(pos)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get('SETPROTOCOL_LIBRARY'), 'Set SETPROTOCOL_LIBRARY')
|
||||||
|
class PlotDragTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.app = QApplication.instance() or QApplication([])
|
||||||
|
cls.core = PlotMath(ctypes.CDLL(os.environ['SETPROTOCOL_LIBRARY']))
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.plot = Canvas(self.core)
|
||||||
|
self.plot.show()
|
||||||
|
self.app.processEvents()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.plot.close()
|
||||||
|
|
||||||
|
def drag(self, start, end, modifiers=Qt.NoModifier):
|
||||||
|
QTest.mousePress(self.plot, Qt.LeftButton, modifiers, start)
|
||||||
|
QTest.mouseRelease(self.plot, Qt.LeftButton, modifiers, end)
|
||||||
|
|
||||||
|
def test_empty_space_pans_x_and_preserves_markers(self):
|
||||||
|
before, markers = self.plot.viewport, self.plot.markers
|
||||||
|
self.drag(QPoint(220, 180), QPoint(260, 210))
|
||||||
|
self.assertEqual(before.transform(self.core, pan_x=.1), self.plot.viewport)
|
||||||
|
self.assertEqual(markers, self.plot.markers)
|
||||||
|
|
||||||
|
def test_shift_over_marker_pans_only_y_and_respects_lock(self):
|
||||||
|
before, markers = self.plot.viewport, self.plot.markers
|
||||||
|
self.drag(QPoint(140, 180), QPoint(180, 200), Qt.ShiftModifier)
|
||||||
|
self.assertEqual(before.transform(self.core, pan_y=.1), self.plot.viewport)
|
||||||
|
self.assertEqual(markers, self.plot.markers)
|
||||||
|
self.plot.lock_y = True
|
||||||
|
before = self.plot.viewport
|
||||||
|
self.drag(QPoint(140, 180), QPoint(180, 200), Qt.ShiftModifier)
|
||||||
|
self.assertEqual(before, self.plot.viewport)
|
||||||
|
|
||||||
|
def test_marker_capture_in_analog_and_digital_panes(self):
|
||||||
|
before = self.plot.viewport
|
||||||
|
# X marker A at x=140 remains draggable in a digital lane.
|
||||||
|
self.drag(QPoint(145, 270), QPoint(185, 290))
|
||||||
|
self.assertAlmostEqual(45, self.plot.markers.a)
|
||||||
|
self.assertEqual(before, self.plot.viewport)
|
||||||
|
# Y marker E at y=120 uses the analog pane's height.
|
||||||
|
self.drag(QPoint(220, 125), QPoint(240, 145))
|
||||||
|
self.assertAlmostEqual(45, self.plot.markers.e)
|
||||||
|
self.assertEqual(before, self.plot.viewport)
|
||||||
|
|
||||||
|
def test_click_miss_and_locked_x_do_not_move_markers(self):
|
||||||
|
before, markers = self.plot.viewport, self.plot.markers
|
||||||
|
self.drag(QPoint(220, 180), QPoint(220, 180))
|
||||||
|
self.assertEqual(before, self.plot.viewport)
|
||||||
|
self.assertEqual(markers, self.plot.markers)
|
||||||
|
self.assertEqual(1, len(self.plot.single_clicks))
|
||||||
|
self.plot.lock_x = True
|
||||||
|
self.drag(QPoint(220, 180), QPoint(260, 210))
|
||||||
|
self.assertEqual(before, self.plot.viewport)
|
||||||
|
self.assertEqual(markers, self.plot.markers)
|
||||||
|
self.assertEqual(1, len(self.plot.single_clicks))
|
||||||
|
|
||||||
|
def test_marker_click_and_drag_returning_to_start_are_not_single_clicks(self):
|
||||||
|
point = QPoint(220, 180)
|
||||||
|
self.drag(QPoint(140, 180), QPoint(140, 180))
|
||||||
|
QTest.mousePress(self.plot, Qt.LeftButton, pos=point)
|
||||||
|
moved = point + QPoint(40, 0)
|
||||||
|
event = QMouseEvent(QEvent.MouseMove, QPointF(moved),
|
||||||
|
QPointF(self.plot.mapToGlobal(moved)),
|
||||||
|
Qt.NoButton, Qt.LeftButton, Qt.NoModifier)
|
||||||
|
self.app.sendEvent(self.plot, event)
|
||||||
|
QTest.mouseRelease(self.plot, Qt.LeftButton, pos=point)
|
||||||
|
self.assertEqual([], self.plot.single_clicks)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user