Replace matplotlib with pyqtgraph in Plot tab

- pg.PlotWidget (2×2 grid) with DateAxisItem for proper time axis
- Built-in pan (left drag), zoom (right drag / scroll wheel), Reset zoom button
- pg.InfiniteLine cursor with pg.SignalProxy for smooth 30 fps tracking
- Cursor value label shows nearest-point values colour-coded per trace
- Legend auto-updated via pi.legend.clear() + named plot() calls
- matplotlib removed from requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:08:58 +02:00
co-authored by Claude Sonnet 4.6
parent 2bbfcc7084
commit b38f8d052e
2 changed files with 177 additions and 46 deletions
+176 -45
View File
@@ -21,19 +21,18 @@ from datetime import datetime
from PyQt5.QtCore import QObject, Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont
from PyQt5.QtWidgets import (
QApplication, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QMainWindow, QPushButton, QSizePolicy,
QApplication, QComboBox, QFormLayout, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSizePolicy,
QSpinBox, QStatusBar, QTabWidget, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget,
)
import matplotlib
matplotlib.use("Qt5Agg")
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import pyqtgraph as pg
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
MAX_PLOT_POINTS = 200
PLOT_COLS = 2
# ── TCP reader ────────────────────────────────────────────────────────────────
@@ -270,26 +269,108 @@ class DashboardTab(QWidget):
# ── Plot tab ──────────────────────────────────────────────────────────────────
class PlotTab(QWidget):
NUM_PLOTS = 4
TRACES = 4
COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(4)
self._fig = Figure(tight_layout=True)
self._canvas = FigureCanvas(self._fig)
self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
layout.addWidget(self._canvas)
# ── trace selectors ───────────────────────────────────────────────────
sel = QWidget()
sel.setMaximumHeight(120)
sel_grid = QGridLayout(sel)
sel_grid.setContentsMargins(0, 0, 0, 0)
sel_grid.setSpacing(3)
self._series: dict[str, deque] = {} # path → deque[(datetime, float)]
self._units: dict[str, str] = {} # path → unit string
self._axes: dict[str, object] = {} # path → matplotlib Axes
self._combos: list[list[QComboBox]] = []
for i in range(self.NUM_PLOTS):
lbl = QLabel(f"<b>Plot {i + 1}</b>")
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
sel_grid.addWidget(lbl, i, 0)
row: list[QComboBox] = []
for j in range(self.TRACES):
cb = QComboBox()
cb.setMinimumWidth(160)
cb.addItem("")
cb.currentIndexChanged.connect(self._on_selection_changed)
sel_grid.addWidget(cb, i, j + 1)
row.append(cb)
self._combos.append(row)
layout.addWidget(sel)
# ── toolbar row ───────────────────────────────────────────────────────
tb = QWidget()
tb_layout = QHBoxLayout(tb)
tb_layout.setContentsMargins(0, 0, 0, 0)
reset_btn = QPushButton("Reset zoom")
reset_btn.setFixedWidth(100)
reset_btn.clicked.connect(self._reset_zoom)
tb_layout.addWidget(reset_btn)
self._cursor_label = QLabel("")
self._cursor_label.setTextFormat(Qt.RichText)
self._cursor_label.setStyleSheet("font-size: 11px; color: #333;")
tb_layout.addWidget(self._cursor_label)
tb_layout.addStretch()
layout.addWidget(tb)
# ── 2×2 pyqtgraph plot grid ───────────────────────────────────────────
plot_container = QWidget()
plot_grid = QGridLayout(plot_container)
plot_grid.setContentsMargins(0, 0, 0, 0)
plot_grid.setSpacing(4)
self._plot_widgets: list[pg.PlotWidget] = []
self._vlines: list[pg.InfiniteLine] = []
self._proxies: list = [] # keep SignalProxy refs alive
for i in range(self.NUM_PLOTS):
pw = pg.PlotWidget(
background="white",
axisItems={"bottom": pg.DateAxisItem(orientation="bottom")},
)
pw.showGrid(x=True, y=True, alpha=0.3)
pw.getAxis("bottom").setStyle(tickFont=QFont("", 7))
pw.getAxis("left").setStyle(tickFont=QFont("", 7))
pw.addLegend(offset=(0, 0), labelTextSize="7pt")
pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
vl = pg.InfiniteLine(
angle=90, movable=False,
pen=pg.mkPen("gray", width=1, style=Qt.DashLine),
)
vl.setVisible(False)
pw.addItem(vl)
self._vlines.append(vl)
proxy = pg.SignalProxy(
pw.scene().sigMouseMoved,
rateLimit=30,
slot=lambda evt, idx=i: self._on_mouse_move(evt, idx),
)
self._proxies.append(proxy)
plot_grid.addWidget(pw, i // 2, i % 2)
self._plot_widgets.append(pw)
layout.addWidget(plot_container)
# ── data ──────────────────────────────────────────────────────────────
self._series: dict[str, deque] = {}
self._units: dict[str, str] = {}
# ── public update called on every new snapshot ────────────────────────────
def update(self, snapshot: dict):
ts_raw = snapshot.get("ts", "")
try:
ts = datetime.fromisoformat(ts_raw)
ts = datetime.fromisoformat(ts_raw).timestamp()
except (ValueError, TypeError):
ts = datetime.utcnow()
ts = datetime.utcnow().timestamp()
phys = _extract_phys(snapshot)
if not phys:
@@ -307,44 +388,94 @@ class PlotTab(QWidget):
pass
if new_keys:
self._rebuild_layout()
self._refresh_combos()
self._redraw()
def _rebuild_layout(self):
keys = list(self._series.keys())
n = len(keys)
cols = min(PLOT_COLS, n)
rows = (n + cols - 1) // cols
# ── combo management ──────────────────────────────────────────────────────
self._fig.clear()
self._axes = {}
def _refresh_combos(self):
signals = sorted(self._series.keys())
for row in self._combos:
for cb in row:
current = cb.currentText()
cb.blockSignals(True)
cb.clear()
cb.addItem("")
for s in signals:
cb.addItem(s)
idx = cb.findText(current)
cb.setCurrentIndex(idx if idx >= 0 else 0)
cb.blockSignals(False)
for i, k in enumerate(keys):
ax = self._fig.add_subplot(rows, cols, i + 1)
label = k.split(".")[-1]
ax.set_title(label, fontsize=9, pad=4)
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
ax.tick_params(axis="both", labelsize=7)
ax.grid(True, linewidth=0.4, alpha=0.5)
self._axes[k] = ax
def _on_selection_changed(self):
self._redraw()
# ── drawing ───────────────────────────────────────────────────────────────
def _redraw(self):
for k, ax in self._axes.items():
series = self._series.get(k)
for i, pw in enumerate(self._plot_widgets):
pi = pw.getPlotItem()
pi.clear()
pi.addItem(self._vlines[i])
if pi.legend:
pi.legend.clear()
for j, cb in enumerate(self._combos[i]):
key = cb.currentText()
if key == "" or key not in self._series:
continue
series = self._series[key]
if not series:
continue
times, values = zip(*series)
label = f"{key.split('.')[-1]} [{self._units.get(key, '')}]"
pw.plot(
list(times), list(values),
pen=pg.mkPen(self.COLORS[j], width=1.5),
name=label,
)
# ── zoom reset ────────────────────────────────────────────────────────────
def _reset_zoom(self):
for pw in self._plot_widgets:
pw.getViewBox().autoRange()
# ── cursor tracing ────────────────────────────────────────────────────────
def _on_mouse_move(self, evt, plot_idx: int):
pos = evt[0]
pw = self._plot_widgets[plot_idx]
vl = self._vlines[plot_idx]
if not pw.sceneBoundingRect().contains(pos):
vl.setVisible(False)
self._cursor_label.setText("")
return
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
vl.setPos(x)
vl.setVisible(True)
parts = []
for j, cb in enumerate(self._combos[plot_idx]):
key = cb.currentText()
if key == "" or key not in self._series:
continue
series = list(self._series[key])
if not series:
continue
times, values = zip(*series)
ax.clear()
ax.set_title(k.split(".")[-1], fontsize=9, pad=4)
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
ax.tick_params(axis="both", labelsize=7)
ax.grid(True, linewidth=0.4, alpha=0.5)
ax.plot(times, values, linewidth=1.4, color="#1a6eb5")
if len(times) > 1:
ax.set_xlim(times[0], times[-1])
self._fig.autofmt_xdate(rotation=25, ha="right")
self._canvas.draw_idle()
_, nearest_v = min(series, key=lambda p: abs(p[0] - x))
label = key.split(".")[-1]
unit = self._units.get(key, "")
color = self.COLORS[j]
parts.append(
f'<span style="color:{color}">&#9632;</span>'
f'&nbsp;{label}:&nbsp;<b>{nearest_v:.2f}</b>&nbsp;{unit}'
)
self._cursor_label.setText("&nbsp;&nbsp;&nbsp;".join(parts))
# ── Main window ───────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,3 +1,3 @@
weconnect
PyQt5
matplotlib
pyqtgraph