Добавить общее перетаскивание графиков и независимые шкалы амплитуд

This commit is contained in:
2026-10-02 16:41:26 +03:00
parent 025a3fde28
commit 3463e95eb3
3 changed files with 361 additions and 0 deletions

View 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."""

View 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()

View 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()